4809 lines
194 KiB
JavaScript
4809 lines
194 KiB
JavaScript
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
|
||
|
||
// Dropdown options for "Add Account" modal: value -> label
|
||
const HOSTER_ADD_OPTIONS = [
|
||
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
|
||
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
|
||
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
|
||
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
|
||
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
|
||
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
|
||
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
|
||
];
|
||
|
||
// --- State ---
|
||
let selectedFiles = []; // { path, name, size }
|
||
let selectedUploadHosters = [];
|
||
let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
|
||
let hosterSettings = {};
|
||
let uploading = false;
|
||
let healthCheckRunning = false;
|
||
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
|
||
let editingAccountId = null; // null = adding, string = editing account by ID
|
||
let autoHealthCheckEnabled = true;
|
||
let queuePersistTimer = null;
|
||
let settingsSaveTimer = null;
|
||
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
||
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
||
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
|
||
const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']);
|
||
|
||
function isStartableQueueStatus(status) {
|
||
return STARTABLE_QUEUE_STATUSES.has(status);
|
||
}
|
||
|
||
function isStartableQueueJob(job) {
|
||
return !!job && isStartableQueueStatus(job.status);
|
||
}
|
||
|
||
// Queue state
|
||
let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link }
|
||
const _jobIndexById = new Map(); // id -> job (O(1) lookup)
|
||
const _jobIndexByUploadId = new Map(); // uploadId -> job
|
||
const selectedJobIds = new Set();
|
||
let _sessionTotalBytes = 0; // Total bytes ever added to queue this session
|
||
let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs)
|
||
const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes
|
||
const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
|
||
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
|
||
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
|
||
// Coalesce removeFromQueueOnDone removals into one filter pass per microtask
|
||
// to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now
|
||
// lives in lib/coalesced-set.js so it can be unit-tested with a manual
|
||
// scheduler. Optional-chained so the renderer still works if the script
|
||
// failed to load — falls back to immediate per-event filter (legacy slow
|
||
// path), better than crashing.
|
||
const _doneRemovalCoalescer = window.CoalescedSet
|
||
? window.CoalescedSet.makeCoalescedSet({
|
||
apply: (drop) => { queueJobs = queueJobs.filter(j => !drop.has(j.id)); }
|
||
})
|
||
: null;
|
||
const queueSortState = { key: 'filename', direction: 'asc' };
|
||
|
||
// History state
|
||
let historyRowsData = [];
|
||
let historySortState = { key: 'date', direction: 'desc' };
|
||
|
||
// Session-specific files for the "Files" panel (resets each session)
|
||
let sessionFilesData = [];
|
||
const recentSortState = { key: 'date', direction: 'desc' };
|
||
const selectedRecentIds = new Set();
|
||
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
|
||
let _sessionDoneCount = 0;
|
||
let _sessionErrorCount = 0;
|
||
// O(1) dedup for maybeAddSessionFile (was O(n) sessionFilesData.some).
|
||
// Huge with thousands of rows × thousands of incoming results.
|
||
const _sessionFileKeys = new Set();
|
||
|
||
// --- Init ---
|
||
async function init() {
|
||
config = await window.api.getConfig();
|
||
hosterSettings = config.hosterSettings || {};
|
||
autoHealthCheckEnabled = loadAutoCheckPreference();
|
||
ensureAccountStatusEntries();
|
||
syncSelectedUploadHosters();
|
||
restoreQueueStateFromConfig();
|
||
await _autoDeduplicateFromLog();
|
||
renderHosterSummary();
|
||
renderHosterModal();
|
||
renderSettings();
|
||
renderAccounts();
|
||
setupListeners();
|
||
setupDragDrop();
|
||
restoreQueueColumnWidths();
|
||
loadHistory();
|
||
_refreshSessionFailedSnapshot();
|
||
renderRecentUploadsPanel();
|
||
updateUploadView();
|
||
updateStatusBar();
|
||
|
||
// Version display
|
||
try {
|
||
const version = await window.api.getVersion();
|
||
const versionLabel = document.getElementById('versionLabel');
|
||
if (versionLabel) versionLabel.textContent = `v${version}`;
|
||
} catch {}
|
||
|
||
// Update listeners
|
||
window.api.onUpdateAvailable(showUpdateBanner);
|
||
window.api.onUpdateProgress(handleUpdateProgress);
|
||
|
||
window.api.onUploadProgress((data) => {
|
||
handleProgress(data);
|
||
});
|
||
if (window.api.onUploadProgressBatch) {
|
||
window.api.onUploadProgressBatch((batch) => {
|
||
if (!Array.isArray(batch)) return;
|
||
for (let i = 0; i < batch.length; i++) handleProgress(batch[i]);
|
||
});
|
||
}
|
||
window.api.onUploadBatchDone((data) => {
|
||
handleBatchDone(data);
|
||
});
|
||
window.api.onUploadStats((data) => {
|
||
handleStats(data);
|
||
});
|
||
window.api.onShutdownCountdown(handleShutdownCountdown);
|
||
window.api.onUploadLogFallback((data) => {
|
||
const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)';
|
||
showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000);
|
||
});
|
||
window.api.onLogPathAutoUpdated((data) => {
|
||
if (!data || !data.logFilePath) return;
|
||
// Keep the in-memory config and the visible Settings input in sync so
|
||
// the user sees the path that's actually being written to, and the
|
||
// next save from the UI doesn't revert it.
|
||
if (config && config.globalSettings) config.globalSettings.logFilePath = data.logFilePath;
|
||
const input = document.getElementById('logFilePathInput');
|
||
if (input) input.value = data.logFilePath;
|
||
showCopyToast(`Log-Pfad automatisch auf funktionierenden Ordner gesetzt`, 5000);
|
||
});
|
||
window.api.onAccountRotationLog((entry) => {
|
||
// Surface only the user-visible rotation events as toasts; full detail
|
||
// goes to account-rotation.log. Keep it quiet otherwise.
|
||
if (!entry || !entry.event) return;
|
||
const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : '';
|
||
if (entry.event === 'rotate') {
|
||
showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`);
|
||
} else if (entry.event === 'rotation-end') {
|
||
showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`);
|
||
} else if (entry.event === 'final-error') {
|
||
showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`);
|
||
}
|
||
});
|
||
|
||
// Folder monitor: auto-queue new files
|
||
window.api.onFolderMonitorNewFiles((files) => {
|
||
window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)');
|
||
const fm = config.globalSettings && config.globalSettings.folderMonitor;
|
||
const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : [];
|
||
|
||
if (fmHosters.length > 0) {
|
||
// Pre-selected hosters: set them as active selection and add directly to queue
|
||
selectedUploadHosters = fmHosters.slice();
|
||
const existing = new Set();
|
||
for (const f of selectedFiles) existing.add(f.path);
|
||
for (const f of _pendingFiles) existing.add(f.path);
|
||
const newFiles = [];
|
||
for (const p of files) {
|
||
if (existing.has(p)) continue;
|
||
existing.add(p);
|
||
const name = p.split('\\').pop().split('/').pop();
|
||
newFiles.push({ path: p, name, size: null });
|
||
}
|
||
if (newFiles.length > 0) {
|
||
const newPaths = new Set(newFiles.map(f => f.path));
|
||
selectedFiles.push(...newFiles);
|
||
buildQueuePreview();
|
||
updateUploadView();
|
||
if (fm.autoStart && !uploading && !healthCheckRunning) {
|
||
startUpload();
|
||
} else if (uploading) {
|
||
// Inject new preview jobs into the running batch
|
||
const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file));
|
||
if (newJobs.length > 0) {
|
||
newJobs.forEach(j => { j.status = 'queued'; });
|
||
renderQueueTable();
|
||
window.api.addJobsToBatch({
|
||
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
|
||
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
|
||
persistQueueStateSoon(true);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No pre-selected hosters: open modal
|
||
addPathsToQueue(files);
|
||
}
|
||
});
|
||
|
||
// Account switched notification
|
||
window.api.onAccountSwitched((data) => {
|
||
window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`);
|
||
});
|
||
|
||
// Drop target window: files dropped on the small floating window
|
||
window.api.onDropTargetFiles((paths) => {
|
||
addPathsToQueue(paths);
|
||
});
|
||
|
||
// Remote client count updates (registered once, not per renderSettings call)
|
||
window.api.onRemoteClientCount(() => {
|
||
const el = document.getElementById('remoteConnectionStatus');
|
||
if (el && el.style.color === 'rgb(16, 185, 129)') {
|
||
window.api.remoteStatus().then(status => {
|
||
if (status.running) {
|
||
el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
});
|
||
|
||
window.api.debugLog('init complete, all listeners registered');
|
||
|
||
// Restore always-on-top state
|
||
try {
|
||
const onTop = await window.api.getAlwaysOnTop();
|
||
alwaysOnTopState = !!onTop;
|
||
} catch {}
|
||
|
||
scheduleStartupAccountCheck();
|
||
}
|
||
|
||
// --- Tab switching ---
|
||
let _historyDirty = false;
|
||
function _isHistoryTabActive() {
|
||
const tab = document.querySelector('.tab.active');
|
||
return !!(tab && tab.dataset.view === 'history');
|
||
}
|
||
// Cache the tab/view collections once and use event delegation on the parent
|
||
// so tab switches don't trigger three querySelectorAll walks per click.
|
||
(() => {
|
||
const tabs = Array.from(document.querySelectorAll('.tab'));
|
||
const views = Array.from(document.querySelectorAll('.view'));
|
||
const tabsByView = {};
|
||
const viewsById = {};
|
||
for (const t of tabs) tabsByView[t.dataset.view] = t;
|
||
for (const v of views) viewsById[v.id] = v;
|
||
let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0];
|
||
|
||
const handle = (target) => {
|
||
const tab = target.closest('.tab');
|
||
if (!tab || tab === activeTab) return;
|
||
if (activeTab) {
|
||
activeTab.classList.remove('active');
|
||
const prevView = viewsById[`${activeTab.dataset.view}-view`];
|
||
if (prevView) prevView.classList.remove('active');
|
||
}
|
||
tab.classList.add('active');
|
||
const nextView = viewsById[`${tab.dataset.view}-view`];
|
||
if (nextView) nextView.classList.add('active');
|
||
activeTab = tab;
|
||
if (tab.dataset.view === 'history') {
|
||
_historyDirty = false;
|
||
loadHistory();
|
||
}
|
||
};
|
||
|
||
const tabBar = tabs[0] && tabs[0].parentElement;
|
||
if (tabBar) {
|
||
tabBar.addEventListener('click', (e) => handle(e.target));
|
||
} else {
|
||
// Fallback: bind per-tab if somehow no common parent
|
||
tabs.forEach(t => t.addEventListener('click', () => handle(t)));
|
||
}
|
||
})();
|
||
|
||
// --- Hoster selection ---
|
||
function accountHasCreds(name, account) {
|
||
if (!account) return false;
|
||
if (account.authType === 'api') return !!account.apiKey;
|
||
if (account.authType === 'login') return !!(account.username && account.password);
|
||
// Fallback
|
||
if (name === 'vidmoly.me') return !!(account.username && account.password);
|
||
if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey;
|
||
return !!account.apiKey;
|
||
}
|
||
|
||
// Returns hosters that have at least one enabled account with credentials
|
||
function getAvailableHosters() {
|
||
const result = [];
|
||
for (const name of HOSTERS) {
|
||
const accounts = config.hosters[name];
|
||
if (!Array.isArray(accounts)) continue;
|
||
const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
|
||
if (hasEnabledAccount) result.push({ name });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function syncSelectedUploadHosters() {
|
||
const available = new Set(getAvailableHosters().map(item => item.name));
|
||
selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name));
|
||
if (selectedUploadHosters.length === 0) {
|
||
selectedUploadHosters = HOSTERS.filter(name => {
|
||
const accounts = config.hosters[name];
|
||
return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
|
||
});
|
||
}
|
||
}
|
||
|
||
function getSelectedHosters() {
|
||
return selectedUploadHosters.slice();
|
||
}
|
||
|
||
function getHosterLabel(name) {
|
||
const labels = {
|
||
'doodstream.com': 'Doodstream',
|
||
'voe.sx': 'VOE',
|
||
'vidmoly.me': 'Vidmoly',
|
||
'byse.sx': 'Byse',
|
||
'clouddrop.cc': 'Clouddrop'
|
||
};
|
||
return labels[name] || name;
|
||
}
|
||
|
||
function getAccountAuthLabel(account) {
|
||
if (!account) return '';
|
||
if (account.authType === 'api') return 'API';
|
||
if (account.authType === 'login') return 'Web Login';
|
||
return '';
|
||
}
|
||
|
||
function getAccountDisplayName(name, account) {
|
||
const authLabel = getAccountAuthLabel(account);
|
||
return authLabel
|
||
? `${getHosterLabel(name)} (${authLabel})`
|
||
: getHosterLabel(name);
|
||
}
|
||
|
||
function maskCredential(value, keep = 4) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '';
|
||
if (text.length <= keep) return text;
|
||
return `${text.slice(0, keep)}…${text.slice(-2)}`;
|
||
}
|
||
|
||
function ensureAccountStatusEntries() {
|
||
const nextStatuses = {};
|
||
for (const { account } of getAllAccountsFlat()) {
|
||
if (account.id) {
|
||
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
|
||
}
|
||
}
|
||
accountStatuses = nextStatuses;
|
||
}
|
||
|
||
// Returns flat array of all accounts: [{ name, account, index }]
|
||
function getAllAccountsFlat() {
|
||
const result = [];
|
||
for (const name of HOSTERS) {
|
||
const accounts = config.hosters[name];
|
||
if (!Array.isArray(accounts)) continue;
|
||
accounts.forEach((account, index) => result.push({ name, account, index }));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Returns flat array of accounts with credentials
|
||
function getAccountsWithCredsFlat() {
|
||
return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account));
|
||
}
|
||
|
||
// Find account by ID across all hosters
|
||
function findAccountById(accountId) {
|
||
for (const name of HOSTERS) {
|
||
const accounts = config.hosters[name];
|
||
if (!Array.isArray(accounts)) continue;
|
||
const account = accounts.find(a => a.id === accountId);
|
||
if (account) return { name, account };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function scheduleStartupAccountCheck() {
|
||
const accounts = getAccountsWithCredsFlat();
|
||
if (!accounts.length) return;
|
||
setTimeout(() => {
|
||
runHealthCheck('startup').catch(() => {});
|
||
}, 500);
|
||
}
|
||
|
||
function renderHosterSummary() {
|
||
const summary = document.getElementById('hosterSummary');
|
||
if (!summary) return;
|
||
const hosters = getSelectedHosters();
|
||
if (hosters.length === 0) {
|
||
summary.textContent = 'Keine Upload-Ziele ausgewählt';
|
||
} else if (hosters.length === 1) {
|
||
summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`;
|
||
} else {
|
||
summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`;
|
||
}
|
||
}
|
||
|
||
function renderHosterModal() {
|
||
const list = document.getElementById('hosterModalList');
|
||
const hint = document.getElementById('hosterModalHint');
|
||
if (!list || !hint) return;
|
||
|
||
const available = getAvailableHosters();
|
||
if (available.length === 0) {
|
||
list.innerHTML = '';
|
||
hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = available.map(item => {
|
||
const checked = selectedUploadHosters.includes(item.name);
|
||
// Get first enabled account's status for subtitle
|
||
const accounts = config.hosters[item.name] || [];
|
||
const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a));
|
||
const accountCount = enabledAccounts.length;
|
||
let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`;
|
||
// Check if any account has ok status
|
||
const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok');
|
||
const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error');
|
||
if (hasOk) subtitle += ' • Bereit';
|
||
else if (hasError) subtitle += ' • Fehler';
|
||
return `
|
||
<label class="hoster-option${checked ? ' selected' : ''}" data-hoster-option="${item.name}">
|
||
<input type="checkbox" data-hoster-modal="${item.name}" ${checked ? 'checked' : ''}>
|
||
<div class="hoster-option-main">
|
||
<div class="hoster-option-title">${escapeHtml(getHosterLabel(item.name))}</div>
|
||
<div class="hoster-option-subtitle">${subtitle}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
|
||
hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.';
|
||
|
||
list.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
input.closest('.hoster-option')?.classList.toggle('selected', input.checked);
|
||
});
|
||
});
|
||
}
|
||
|
||
function openHosterModal() {
|
||
syncSelectedUploadHosters();
|
||
renderHosterModal();
|
||
document.getElementById('hosterModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeHosterModal() {
|
||
const modal = document.getElementById('hosterModal');
|
||
if (modal) modal.style.display = 'none';
|
||
}
|
||
|
||
function applyHosterSelection() {
|
||
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
|
||
.map(input => input.dataset.hosterModal);
|
||
// Move pending files to selectedFiles on confirm
|
||
const pendingPaths = new Set(_pendingFiles.map(f => f.path));
|
||
if (_pendingFiles.length > 0) {
|
||
selectedFiles.push(..._pendingFiles);
|
||
_pendingFiles = [];
|
||
}
|
||
renderHosterSummary();
|
||
|
||
// During an active upload, build preview jobs for the new files and inject
|
||
// them into the running batch immediately (otherwise they'd be lost on
|
||
// handleBatchDone via syncSelectedFilesFromQueue)
|
||
if (uploading && pendingPaths.size > 0) {
|
||
buildQueuePreview(); // creates 'preview' jobs for new files
|
||
const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file));
|
||
if (newJobs.length > 0) {
|
||
newJobs.forEach(j => { j.status = 'queued'; });
|
||
renderQueueTable();
|
||
window.api.addJobsToBatch({
|
||
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
|
||
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
|
||
persistQueueStateSoon(true);
|
||
}
|
||
}
|
||
|
||
updateUploadView();
|
||
persistQueueStateSoon(true); // immediate persist after adding files
|
||
document.getElementById('hosterModal').style.display = 'none';
|
||
}
|
||
|
||
function cancelHosterModal() {
|
||
_pendingFiles = [];
|
||
closeHosterModal();
|
||
}
|
||
|
||
function normalizeRestoredJobStatus(status) {
|
||
if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status;
|
||
return 'queued';
|
||
}
|
||
|
||
function restoreQueueStateFromConfig() {
|
||
if (config?.globalSettings?.resumeQueueOnLaunch === false) return;
|
||
const pending = config?.globalSettings?.pendingQueue;
|
||
if (!pending || typeof pending !== 'object') return;
|
||
|
||
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
|
||
? pending.selectedUploadHosters.filter(Boolean)
|
||
: selectedUploadHosters;
|
||
|
||
selectedFiles = Array.isArray(pending.selectedFiles)
|
||
? pending.selectedFiles
|
||
.filter(file => file && file.path)
|
||
.map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 }))
|
||
: [];
|
||
|
||
const rawJobs = Array.isArray(pending.queueJobs)
|
||
? pending.queueJobs
|
||
.filter(job => job && job.fileName && job.hoster)
|
||
.map(job => ({
|
||
id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
uploadId: null,
|
||
file: job.file || '',
|
||
fileName: job.fileName,
|
||
hoster: job.hoster,
|
||
status: normalizeRestoredJobStatus(job.status),
|
||
bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
|
||
bytesTotal: job.bytesTotal || 0,
|
||
speedKbs: 0,
|
||
elapsed: 0,
|
||
remaining: 0,
|
||
error: job.error || null,
|
||
result: job.result || null,
|
||
attempt: 0,
|
||
maxAttempts: job.maxAttempts || 0,
|
||
link: '',
|
||
progress: job.status === 'done' ? 1 : 0
|
||
}))
|
||
: [];
|
||
|
||
// Deduplicate: keep the job with the best status for each file+hoster pair
|
||
const seen = new Map();
|
||
const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 };
|
||
for (const job of rawJobs) {
|
||
const key = `${job.file}|${job.hoster}`;
|
||
const existing = seen.get(key);
|
||
if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) {
|
||
seen.set(key, job);
|
||
}
|
||
}
|
||
queueJobs = Array.from(seen.values());
|
||
rebuildJobIndex();
|
||
}
|
||
|
||
function buildPersistedQueueState() {
|
||
const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status));
|
||
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
|
||
|
||
for (const job of persistableJobs) {
|
||
if (job.file && !selectedFileMap.has(job.file)) {
|
||
selectedFileMap.set(job.file, {
|
||
path: job.file,
|
||
name: job.fileName,
|
||
size: job.bytesTotal || 0
|
||
});
|
||
}
|
||
}
|
||
|
||
if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) {
|
||
return null;
|
||
}
|
||
|
||
// After a restart no upload manager is running, so any in-flight state
|
||
// (queued / getting-server / uploading / retrying / aborted) is
|
||
// meaningless. Collapse them all to 'preview' so the queue shows a
|
||
// consistent "Bereit" for everything that didn't actually terminate.
|
||
// Only true terminal states (done / error / skipped) survive as-is.
|
||
const TERMINAL = new Set(['done', 'error', 'skipped']);
|
||
return {
|
||
selectedUploadHosters: getSelectedHosters(),
|
||
selectedFiles: Array.from(selectedFileMap.values()),
|
||
queueJobs: queueJobs.map(job => {
|
||
const isTerminal = TERMINAL.has(job.status);
|
||
return {
|
||
id: job.id,
|
||
file: job.file,
|
||
fileName: job.fileName,
|
||
hoster: job.hoster,
|
||
status: isTerminal ? job.status : 'preview',
|
||
bytesTotal: job.bytesTotal || 0,
|
||
error: isTerminal ? (job.error || null) : null,
|
||
result: isTerminal ? (job.result || null) : null,
|
||
maxAttempts: job.maxAttempts || 0
|
||
};
|
||
})
|
||
};
|
||
}
|
||
|
||
async function persistQueueStateNow() {
|
||
const globalSettings = {
|
||
...(config.globalSettings || {}),
|
||
pendingQueue: buildPersistedQueueState()
|
||
};
|
||
config.globalSettings = globalSettings;
|
||
await window.api.saveGlobalSettings(globalSettings);
|
||
}
|
||
|
||
function persistQueueStateSoon(immediate) {
|
||
clearTimeout(queuePersistTimer);
|
||
if (immediate) {
|
||
persistQueueStateNow().catch(() => {});
|
||
return;
|
||
}
|
||
// Use longer debounce during uploads to reduce disk I/O
|
||
const delay = uploading ? 10000 : 500;
|
||
queuePersistTimer = setTimeout(() => {
|
||
persistQueueStateNow().catch(() => {});
|
||
}, delay);
|
||
}
|
||
|
||
function clearPersistedQueueStateSoon() {
|
||
clearTimeout(queuePersistTimer);
|
||
queuePersistTimer = setTimeout(() => {
|
||
const globalSettings = {
|
||
...(config.globalSettings || {}),
|
||
pendingQueue: null
|
||
};
|
||
config.globalSettings = globalSettings;
|
||
window.api.saveGlobalSettings(globalSettings).catch(() => {});
|
||
}, 0);
|
||
}
|
||
|
||
// --- File selection ---
|
||
function setupDragDrop() {
|
||
const dropZone = document.getElementById('dropZone');
|
||
// Allow drop on the entire upload view
|
||
const uploadView = document.getElementById('upload-view');
|
||
let _dragCounter = 0;
|
||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
|
||
dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); });
|
||
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over');
|
||
addDroppedFiles(e.dataTransfer.files).catch(console.error);
|
||
});
|
||
dropZone.addEventListener('click', () => pickFiles());
|
||
|
||
// Also handle drops on queue container
|
||
uploadView.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||
uploadView.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
if (e.target.closest('.drop-zone')) return; // handled above
|
||
addDroppedFiles(e.dataTransfer.files).catch(console.error);
|
||
});
|
||
}
|
||
|
||
let _pendingFiles = []; // Files waiting for hoster modal confirmation
|
||
|
||
let _addingDropped = false;
|
||
|
||
async function addDroppedFiles(fileList) {
|
||
if (_addingDropped) return;
|
||
_addingDropped = true;
|
||
try {
|
||
const files = Array.from(fileList);
|
||
const existingPaths = new Set([
|
||
...selectedFiles.map(f => f.path),
|
||
..._pendingFiles.map(f => f.path)
|
||
]);
|
||
const newFiles = [];
|
||
|
||
for (const file of files) {
|
||
let filePath = '';
|
||
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
|
||
if (!filePath) continue;
|
||
|
||
// Detect folders: directories report size 0 and empty type in Electron drag-and-drop
|
||
if (file.type === '' && file.size === 0) {
|
||
try {
|
||
const folderFiles = await window.api.resolveFolderFiles(filePath);
|
||
if (folderFiles && folderFiles.length > 0) {
|
||
for (const fp of folderFiles) {
|
||
if (!existingPaths.has(fp)) {
|
||
const name = fp.split('\\').pop().split('/').pop();
|
||
newFiles.push({ path: fp, name, size: null });
|
||
existingPaths.add(fp);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Regular file
|
||
const fileName = file.name || '';
|
||
if (!existingPaths.has(filePath)) {
|
||
newFiles.push({ path: filePath, name: fileName, size: file.size });
|
||
existingPaths.add(filePath);
|
||
}
|
||
}
|
||
|
||
if (newFiles.length > 0) {
|
||
_pendingFiles.push(...newFiles);
|
||
openHosterModal();
|
||
}
|
||
} finally {
|
||
_addingDropped = false;
|
||
}
|
||
}
|
||
|
||
async function pickFiles() {
|
||
const paths = await window.api.selectFiles();
|
||
if (!paths) return;
|
||
addPathsToQueue(paths);
|
||
}
|
||
|
||
async function pickFolder() {
|
||
const paths = await window.api.selectFolder();
|
||
if (!paths) return;
|
||
addPathsToQueue(paths);
|
||
}
|
||
|
||
function addPathsToQueue(paths) {
|
||
// Build path-Set once so dedup is O(1) per candidate instead of O(n+m).
|
||
// Matters when the user picks a folder with thousands of files.
|
||
const existing = new Set();
|
||
for (const f of selectedFiles) existing.add(f.path);
|
||
for (const f of _pendingFiles) existing.add(f.path);
|
||
|
||
const newFiles = [];
|
||
for (const p of paths) {
|
||
if (existing.has(p)) continue;
|
||
existing.add(p);
|
||
const name = p.split('\\').pop().split('/').pop();
|
||
newFiles.push({ path: p, name, size: null });
|
||
}
|
||
if (newFiles.length > 0) {
|
||
_pendingFiles.push(...newFiles);
|
||
openHosterModal();
|
||
}
|
||
}
|
||
|
||
function updateUploadView() {
|
||
const dropZone = document.getElementById('dropZone');
|
||
const queueShell = document.getElementById('queueShell');
|
||
const queueActions = document.getElementById('queueActions');
|
||
|
||
if (selectedFiles.length === 0 && queueJobs.length === 0) {
|
||
dropZone.style.display = 'flex';
|
||
queueShell.style.display = 'none';
|
||
queueActions.style.display = 'none';
|
||
} else {
|
||
dropZone.style.display = 'none';
|
||
queueShell.style.display = 'flex';
|
||
queueActions.style.display = 'flex';
|
||
if (!uploading && selectedFiles.length > 0) {
|
||
buildQueuePreview();
|
||
}
|
||
}
|
||
updateQueueActionButtons();
|
||
}
|
||
|
||
function updateStartButton() {
|
||
const btn = document.getElementById('startUploadBtn');
|
||
const hosters = getSelectedHosters();
|
||
const hasQueuedJobs = queueJobs.some(isStartableQueueJob);
|
||
const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0;
|
||
btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
|
||
}
|
||
|
||
const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']);
|
||
const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
|
||
|
||
function updateQueueActionButtons() {
|
||
updateStartButton();
|
||
|
||
const hasSelection = selectedJobIds.size > 0;
|
||
// Single pass over the (usually small) selection set instead of three O(n)
|
||
// scans over the entire queue. For 1000 jobs × 3 scans this drops the
|
||
// selection-change cost from ~3000 checks to |selection|.
|
||
let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false;
|
||
for (const id of selectedJobIds) {
|
||
const job = _jobIndexById.get(id);
|
||
if (!job) continue;
|
||
const s = job.status;
|
||
if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true;
|
||
if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true;
|
||
if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true;
|
||
if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break;
|
||
}
|
||
const hasMovableSelection = hasSelection && !uploading;
|
||
|
||
const startSelectedBtn = document.getElementById('startSelectedBtn');
|
||
const reuploadBtn = document.getElementById('reuploadSelectedBtn');
|
||
const abortSelectedBtn = document.getElementById('abortSelectedBtn');
|
||
const finishStopBtn = document.getElementById('finishStopBtn');
|
||
const abortAllBtn = document.getElementById('abortAllBtn');
|
||
const moveTopBtn = document.getElementById('moveTopBtn');
|
||
const moveUpBtn = document.getElementById('moveUpBtn');
|
||
const moveDownBtn = document.getElementById('moveDownBtn');
|
||
const moveBottomBtn = document.getElementById('moveBottomBtn');
|
||
|
||
if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection;
|
||
if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection;
|
||
if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection;
|
||
if (finishStopBtn) finishStopBtn.disabled = !uploading;
|
||
if (abortAllBtn) abortAllBtn.disabled = !uploading;
|
||
if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection;
|
||
if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection;
|
||
if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection;
|
||
if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection;
|
||
}
|
||
|
||
// Build preview jobs from selected files x selected hosters (before upload starts)
|
||
function buildQueuePreview() {
|
||
const hosters = getSelectedHosters();
|
||
if (hosters.length === 0) {
|
||
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
||
rebuildJobIndex();
|
||
renderQueueTable();
|
||
persistQueueStateSoon();
|
||
return;
|
||
}
|
||
// Remove old preview jobs
|
||
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
||
|
||
// Build a Set for fast existence checks
|
||
const existingKeys = new Set();
|
||
for (const j of queueJobs) {
|
||
if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`);
|
||
}
|
||
|
||
for (const file of selectedFiles) {
|
||
for (const hoster of hosters) {
|
||
const key = `${file.path}|${hoster}`;
|
||
if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) {
|
||
const job = {
|
||
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
file: file.path, fileName: file.name, hoster,
|
||
status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0,
|
||
speedKbs: 0, elapsed: 0, remaining: 0,
|
||
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
|
||
};
|
||
queueJobs.push(job);
|
||
existingKeys.add(key);
|
||
}
|
||
}
|
||
}
|
||
rebuildJobIndex();
|
||
renderQueueTable();
|
||
persistQueueStateSoon();
|
||
}
|
||
|
||
// --- Job Index Management ---
|
||
function rebuildJobIndex() {
|
||
_jobIndexById.clear();
|
||
_jobIndexByUploadId.clear();
|
||
for (const job of queueJobs) {
|
||
_jobIndexById.set(job.id, job);
|
||
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
|
||
}
|
||
}
|
||
|
||
function indexJob(job) {
|
||
_jobIndexById.set(job.id, job);
|
||
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
|
||
}
|
||
|
||
function removeJobFromIndex(job) {
|
||
_jobIndexById.delete(job.id);
|
||
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId);
|
||
// Track deletion so handleProgress() won't re-create this job from stale callbacks
|
||
_deletedJobIds.add(job.id);
|
||
if (job.uploadId) _deletedJobIds.add(job.uploadId);
|
||
// Allow re-uploading same file+hoster after deletion
|
||
if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`);
|
||
}
|
||
|
||
// --- Queue Table Rendering (debounced with virtual scrolling) ---
|
||
let _renderQueued = false;
|
||
let _sortedJobsCache = [];
|
||
const VIRTUAL_ROW_HEIGHT = 28;
|
||
const VIRTUAL_OVERSCAN = 10;
|
||
let _lastVisibleRange = { start: -1, end: -1 };
|
||
let _queueListenersBound = false;
|
||
|
||
// Throttled UI update scheduling – max one render per 200ms during uploads
|
||
let _uiUpdateTimer = null;
|
||
const UI_UPDATE_INTERVAL = 200; // ms
|
||
|
||
function scheduleQueueRender() {
|
||
if (_renderQueued) return;
|
||
_renderQueued = true;
|
||
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
|
||
}
|
||
|
||
let _recentRenderQueued = false;
|
||
function scheduleRecentRender() {
|
||
if (_recentRenderQueued) return;
|
||
_recentRenderQueued = true;
|
||
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
|
||
}
|
||
|
||
// Toggle the .selected class on existing rows without rebuilding the table.
|
||
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
|
||
// Uses getElementsByClassName for the live HTMLCollection (DOM-cached after the
|
||
// first call, auto-tracks insertions/removals on tbody) instead of running a
|
||
// fresh querySelectorAll on every click. At 200 visible rows that's the
|
||
// difference between paying for a tree walk per click vs reading a memoized
|
||
// list that the engine already maintains.
|
||
function applyQueueSelectionClasses() {
|
||
const tbody = document.getElementById('queueBody');
|
||
if (!tbody) return;
|
||
const rows = tbody.getElementsByClassName('queue-row');
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const tr = rows[i];
|
||
tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId));
|
||
}
|
||
}
|
||
|
||
function applyRecentSelectionClasses() {
|
||
const tbody = document.getElementById('recentFilesBody');
|
||
if (!tbody) return;
|
||
const rows = tbody.getElementsByClassName('recent-file-row');
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const tr = rows[i];
|
||
const order = parseInt(tr.dataset.order, 10);
|
||
tr.classList.toggle('selected', selectedRecentIds.has(order));
|
||
}
|
||
}
|
||
|
||
function scheduleThrottledUIUpdate() {
|
||
if (_uiUpdateTimer) return;
|
||
_uiUpdateTimer = setTimeout(() => {
|
||
_uiUpdateTimer = null;
|
||
scheduleQueueRender();
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
updateStatsPanel();
|
||
}, UI_UPDATE_INTERVAL);
|
||
}
|
||
|
||
// Coalesces status-change updates (done/error/retrying/queued/…) into one
|
||
// frame. Without this, a batch of 500 jobs flipping queued→getting-server
|
||
// →uploading synchronously fires 1500+ updateStatusBar/Buttons/Stats calls
|
||
// and janks the renderer. rAF caps it to ~60 Hz.
|
||
let _statusChangeUpdateQueued = false;
|
||
function scheduleStatusChangeUpdate() {
|
||
if (_statusChangeUpdateQueued) return;
|
||
_statusChangeUpdateQueued = true;
|
||
requestAnimationFrame(() => {
|
||
_statusChangeUpdateQueued = false;
|
||
renderQueueTable();
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
updateStatsPanel();
|
||
});
|
||
}
|
||
|
||
function buildRowHtml(job) {
|
||
const statusClass = `status-${job.status}`;
|
||
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
|
||
const uploadedSize = job.status === 'preview'
|
||
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
|
||
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
|
||
const statusText = getStatusText(job);
|
||
const elapsed = formatTime(job.elapsed);
|
||
const remaining = formatTime(job.remaining);
|
||
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
|
||
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
|
||
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
|
||
|
||
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
|
||
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
|
||
<td class="col-size">${uploadedSize}</td>
|
||
<td class="col-host">${escapeHtml(job.hoster)}</td>
|
||
<td class="col-status"><span class="status-badge ${statusClass}">${escapeHtml(statusText)}</span></td>
|
||
<td class="col-elapsed">${elapsed}</td>
|
||
<td class="col-remaining">${remaining}</td>
|
||
<td class="col-speed">${speed}</td>
|
||
<td class="col-progress">
|
||
<div class="progress-cell">
|
||
<div class="progress-bar-bg">
|
||
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
|
||
</div>
|
||
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// In-place update of a single row's cells (avoids full innerHTML rebuild)
|
||
function _updateRowInPlace(tr, job) {
|
||
const statusClass = `status-${job.status}`;
|
||
const uploadedSize = job.status === 'preview'
|
||
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
|
||
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
|
||
const statusText = getStatusText(job);
|
||
const elapsed = formatTime(job.elapsed);
|
||
const remaining = formatTime(job.remaining);
|
||
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
|
||
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
|
||
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
|
||
|
||
// Write DOM only when the target value actually changes — a no-op progress
|
||
// tick (same pct, same speed) then performs zero DOM work. Massive saver
|
||
// when most of the visible jobs are idle/queued/done and only a few are
|
||
// actively uploading.
|
||
const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
|
||
if (tr.className !== newClass) tr.className = newClass;
|
||
if (tr.dataset.link !== link) tr.dataset.link = link;
|
||
|
||
const cells = tr.children;
|
||
if (cells.length < 8) return false; // structure mismatch, needs full rebuild
|
||
|
||
if (cells[1].textContent !== uploadedSize) cells[1].textContent = uploadedSize;
|
||
// cells[0] (filename) and cells[2] (hoster) don't change during upload
|
||
const badge = cells[3].querySelector('.status-badge');
|
||
if (badge) {
|
||
const badgeClass = `status-badge ${statusClass}`;
|
||
if (badge.className !== badgeClass) badge.className = badgeClass;
|
||
if (badge.textContent !== statusText) badge.textContent = statusText;
|
||
}
|
||
if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed;
|
||
if (cells[5].textContent !== remaining) cells[5].textContent = remaining;
|
||
if (cells[6].textContent !== speed) cells[6].textContent = speed;
|
||
|
||
const fill = cells[7].querySelector('.progress-bar-fill');
|
||
if (fill) {
|
||
const pctStr = pct + '%';
|
||
if (fill.style.width !== pctStr) fill.style.width = pctStr;
|
||
const fillClass = `progress-bar-fill ${statusClass}`;
|
||
if (fill.className !== fillClass) fill.className = fillClass;
|
||
}
|
||
const pctSpan = cells[7].querySelector('.progress-pct');
|
||
if (pctSpan) {
|
||
const pctText = job.status === 'preview' ? '' : pct + '%';
|
||
if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function renderQueueTable() {
|
||
const tbody = document.getElementById('queueBody');
|
||
if (!tbody) return;
|
||
|
||
_sortedJobsCache = sortQueueJobs(queueJobs);
|
||
const totalRows = _sortedJobsCache.length;
|
||
|
||
if (totalRows < 200) {
|
||
// Try in-place update if row count matches (fast path)
|
||
const existingRows = tbody.querySelectorAll('.queue-row');
|
||
if (existingRows.length === totalRows && totalRows > 0) {
|
||
// In-place update – no DOM destruction
|
||
for (let i = 0; i < totalRows; i++) {
|
||
const tr = existingRows[i];
|
||
const job = _sortedJobsCache[i];
|
||
// If row identity changed (different job), fall back to full rebuild
|
||
if (tr.dataset.jobId !== job.id) {
|
||
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
|
||
_lastVisibleRange = { start: -1, end: -1 };
|
||
break;
|
||
}
|
||
_updateRowInPlace(tr, job);
|
||
}
|
||
} else {
|
||
// Full rebuild needed (row count changed)
|
||
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
|
||
_lastVisibleRange = { start: -1, end: -1 };
|
||
}
|
||
} else {
|
||
// Virtual scrolling for large queues — in-place update when range unchanged
|
||
_renderVirtualRows(tbody);
|
||
}
|
||
|
||
// Bind event delegation once
|
||
if (!_queueListenersBound) {
|
||
_queueListenersBound = true;
|
||
tbody.addEventListener('click', (e) => {
|
||
const row = e.target.closest('.queue-row');
|
||
if (row) handleRowClick(e, row);
|
||
});
|
||
tbody.addEventListener('contextmenu', (e) => {
|
||
const row = e.target.closest('.queue-row');
|
||
if (row) handleRowContextMenu(e, row);
|
||
});
|
||
}
|
||
|
||
// Update retry button visibility
|
||
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
|
||
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
|
||
updateQueueActionButtons();
|
||
}
|
||
|
||
function _renderVirtualRows(tbody) {
|
||
const scrollContainer = document.getElementById('queueContainer');
|
||
if (!scrollContainer) return;
|
||
|
||
const totalRows = _sortedJobsCache.length;
|
||
const scrollTop = scrollContainer.scrollTop;
|
||
// Use a minimum viewport height to avoid rendering nothing when container is being laid out
|
||
const viewportHeight = Math.max(scrollContainer.clientHeight, 200);
|
||
|
||
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
||
const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
|
||
|
||
// Same range — try in-place update to avoid hover flicker
|
||
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) {
|
||
const rows = tbody.querySelectorAll('.queue-row');
|
||
if (rows.length === endIdx - startIdx) {
|
||
let allMatch = true;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const job = _sortedJobsCache[startIdx + i];
|
||
if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; }
|
||
_updateRowInPlace(rows[i], job);
|
||
}
|
||
if (allMatch) return; // all rows updated in-place, no rebuild needed
|
||
}
|
||
}
|
||
_lastVisibleRange = { start: startIdx, end: endIdx };
|
||
|
||
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
|
||
const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT);
|
||
|
||
let html = '';
|
||
if (topPad > 0) html += `<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="8"></td></tr>`;
|
||
for (let i = startIdx; i < endIdx; i++) {
|
||
html += buildRowHtml(_sortedJobsCache[i]);
|
||
}
|
||
if (bottomPad > 0) html += `<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="8"></td></tr>`;
|
||
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
// Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one
|
||
// render per frame. rAF keeps the scroll thread cheap.
|
||
let _queueScrollQueued = false;
|
||
function _onQueueScroll() {
|
||
if (_queueScrollQueued) return;
|
||
if (_sortedJobsCache.length < 200) return;
|
||
_queueScrollQueued = true;
|
||
requestAnimationFrame(() => {
|
||
_queueScrollQueued = false;
|
||
const tbody = document.getElementById('queueBody');
|
||
if (tbody) _renderVirtualRows(tbody);
|
||
});
|
||
}
|
||
|
||
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||
const _collatorSimple = new Intl.Collator('de');
|
||
|
||
// Queue sort memoization. Keys that don't change after a job enters the queue
|
||
// (filename, host) reuse the cached result across progress-driven re-renders.
|
||
// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when
|
||
// previews resolve / upload starts) are recomputed each call — otherwise a
|
||
// queue sorted by size during previews would be stuck in all-zeros order.
|
||
//
|
||
// CRITICAL: the cache also tracks jobsRef (identity of the queueJobs array) so
|
||
// that a full replacement (e.g. backup import, queue restore) invalidates the
|
||
// cache. Length alone can match across a replace and would otherwise pin the
|
||
// renderer to stale job references — the UI freezes showing old statuses even
|
||
// though queueJobs itself has fresh objects. Observed as "upload runs in
|
||
// status bar but all rows stay 'Bereit'" after importing a backup.
|
||
let _queueSortCache = { sig: '', result: [], jobsRef: null };
|
||
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
|
||
|
||
// Dynamic-key sort throttle: status/speed/progress/size change on every
|
||
// progress tick, so a strict per-call sort is O(N log N) per render. Within
|
||
// one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's
|
||
// slightly out of order — the user can't perceive sub-200ms reorder lag, and
|
||
// at 5000 queued jobs this is the difference between smooth and stuttering.
|
||
// Uses lib/throttled-cache.js (see tests/throttled-cache.test.js).
|
||
const DYNAMIC_SORT_REFRESH_MS = 200;
|
||
const _dynamicSortCache = window.ThrottledCache
|
||
? window.ThrottledCache.makeThrottledCache(DYNAMIC_SORT_REFRESH_MS)
|
||
: { get: () => undefined, set: (s, i, v) => v, clear: () => {} };
|
||
|
||
function sortQueueJobs(jobs) {
|
||
const { key, direction } = queueSortState;
|
||
const factor = direction === 'asc' ? 1 : -1;
|
||
const canCache = _STATIC_SORT_KEYS.has(key);
|
||
const sig = canCache ? `${key}|${direction}|${jobs.length}` : '';
|
||
if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) {
|
||
return _queueSortCache.result;
|
||
}
|
||
// Dynamic-key throttle: same key+direction+array, sorted within the last
|
||
// 200ms → reuse. The cache is keyed by `key|direction` and uses the jobs
|
||
// array identity as the input ref, so a fresh queueJobs (e.g. after
|
||
// backup import) misses correctly.
|
||
if (!canCache) {
|
||
const dynSig = `${key}|${direction}`;
|
||
const cached = _dynamicSortCache.get(dynSig, jobs);
|
||
if (cached) return cached;
|
||
}
|
||
|
||
const sorted = jobs.slice().sort((a, b) => {
|
||
let cmp = 0;
|
||
if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName);
|
||
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
|
||
else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster);
|
||
else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status);
|
||
else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0);
|
||
else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0);
|
||
return cmp * factor;
|
||
});
|
||
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
|
||
else _dynamicSortCache.set(`${key}|${direction}`, jobs, sorted);
|
||
return sorted;
|
||
}
|
||
|
||
function getStatusOrder(status) {
|
||
const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 };
|
||
return order[status] ?? 4;
|
||
}
|
||
|
||
// "Primär" / "Fallback #1" / "Fallback #2"… derived from the job's current
|
||
// accountId position in the configured hoster account list. Returns '' if we
|
||
// can't resolve it (e.g. account was removed mid-session).
|
||
function getAccountLabel(job) {
|
||
if (!job || !job.accountId || !job.hoster) return '';
|
||
const accounts = config && config.hosters && config.hosters[job.hoster];
|
||
if (!Array.isArray(accounts)) return '';
|
||
const idx = accounts.findIndex(a => a && a.id === job.accountId);
|
||
if (idx < 0) return '';
|
||
return idx === 0 ? 'Primär' : `Fallback #${idx}`;
|
||
}
|
||
|
||
function getStatusText(job) {
|
||
const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : '';
|
||
const acc = getAccountLabel(job);
|
||
const accSuffix = acc ? ` · ${acc}` : '';
|
||
switch (job.status) {
|
||
case 'preview': return 'Bereit';
|
||
case 'queued': return 'Wartet';
|
||
case 'getting-server': return `Server...${accSuffix}`;
|
||
case 'uploading': return `Upload${accSuffix}`;
|
||
case 'retrying': {
|
||
const base = `Retry ${job.attempt}/${job.maxAttempts}${accSuffix}`;
|
||
return shortErr ? `${base}: ${shortErr}` : base;
|
||
}
|
||
case 'done': return 'Fertig';
|
||
case 'aborted': return 'Abgebrochen';
|
||
case 'error': return shortErr ? `Fehlgeschlagen: ${shortErr}` : 'Fehlgeschlagen';
|
||
case 'skipped': return shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen';
|
||
default: return job.status;
|
||
}
|
||
}
|
||
|
||
// --- Queue interactions ---
|
||
function handleRowClick(e, row) {
|
||
const jobId = row.dataset.jobId;
|
||
// Clear recent panel selection when clicking in queue — class-toggle only.
|
||
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); }
|
||
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
|
||
else selectedJobIds.add(jobId);
|
||
} else if (e.shiftKey && selectedJobIds.size > 0) {
|
||
// Use sorted jobs cache for correct shift-click with virtual scrolling
|
||
const sortedIds = _sortedJobsCache.map(j => j.id);
|
||
const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id));
|
||
const curIdx = sortedIds.indexOf(jobId);
|
||
if (lastIdx >= 0 && curIdx >= 0) {
|
||
const from = Math.min(lastIdx, curIdx);
|
||
const to = Math.max(lastIdx, curIdx);
|
||
for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]);
|
||
}
|
||
} else {
|
||
selectedJobIds.clear();
|
||
selectedJobIds.add(jobId);
|
||
// Single click on done job -> copy link
|
||
const job = _jobIndexById.get(jobId);
|
||
if (job && job.status === 'done' && job.result) {
|
||
const link = job.result.download_url || job.result.embed_url || '';
|
||
if (link) {
|
||
window.api.copyToClipboard(link);
|
||
showCopyToast('Link kopiert');
|
||
}
|
||
}
|
||
}
|
||
// Selection changes don't change sort order / row content — just toggle classes.
|
||
applyQueueSelectionClasses();
|
||
updateQueueActionButtons();
|
||
}
|
||
|
||
// --- Context menu ---
|
||
let alwaysOnTopState = false;
|
||
|
||
// Cache hoster-counts for the context menu. Invalidated on structural changes
|
||
// to queueJobs (the length-based signature is good enough — a job's hoster
|
||
// never changes after it's created).
|
||
let _hosterCountsCache = { sig: '', result: new Map() };
|
||
function _getHosterCounts() {
|
||
const sig = `${queueJobs.length}`;
|
||
if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result;
|
||
const m = new Map();
|
||
for (let i = 0; i < queueJobs.length; i++) {
|
||
const h = queueJobs[i].hoster;
|
||
m.set(h, (m.get(h) || 0) + 1);
|
||
}
|
||
_hosterCountsCache = { sig, result: m };
|
||
return m;
|
||
}
|
||
|
||
function handleRowContextMenu(e, row) {
|
||
e.preventDefault();
|
||
const jobId = row.dataset.jobId;
|
||
if (!selectedJobIds.has(jobId)) {
|
||
selectedJobIds.clear();
|
||
selectedJobIds.add(jobId);
|
||
applyQueueSelectionClasses();
|
||
updateQueueActionButtons();
|
||
}
|
||
showContextMenu(e.clientX, e.clientY);
|
||
}
|
||
|
||
function showContextMenu(x, y) {
|
||
const menu = document.getElementById('contextMenu');
|
||
// Update "Always on top" text
|
||
const aotItem = menu.querySelector('[data-action="always-on-top"]');
|
||
if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund';
|
||
// Update labels with selection count
|
||
const n = selectedJobIds.size;
|
||
const delItem = menu.querySelector('[data-action="delete-selected"]');
|
||
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
|
||
const copyItem = menu.querySelector('[data-action="copy-links"]');
|
||
if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren';
|
||
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
|
||
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
|
||
});
|
||
const startItem = menu.querySelector('[data-action="start-selected"]');
|
||
if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten';
|
||
|
||
// Dynamic "delete by hoster" submenu — cached count keyed by queue length
|
||
// so a right-click on a 5000-job queue doesn't rescan everything.
|
||
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
|
||
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
|
||
const hosterCounts = _getHosterCounts();
|
||
deleteHosterContainer.innerHTML = '';
|
||
if (hosterCounts.size > 0) {
|
||
deleteHosterSubmenu.style.display = '';
|
||
hosterCounts.forEach((count, hoster) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'ctx-item ctx-item-danger';
|
||
item.dataset.action = `delete-hoster:${hoster}`;
|
||
item.textContent = `${getHosterLabel(hoster)} (${count})`;
|
||
deleteHosterContainer.appendChild(item);
|
||
});
|
||
} else {
|
||
deleteHosterSubmenu.style.display = 'none';
|
||
}
|
||
|
||
menu.style.display = 'block';
|
||
const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5);
|
||
menu.style.left = menuX + 'px';
|
||
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
|
||
|
||
// Flip submenus if they would overflow the viewport right edge
|
||
menu.querySelectorAll('.ctx-submenu-items').forEach(sub => {
|
||
// Temporarily show to measure actual width (display:none → offsetWidth=0)
|
||
sub.style.visibility = 'hidden';
|
||
sub.style.display = 'block';
|
||
sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth);
|
||
sub.style.display = '';
|
||
sub.style.visibility = '';
|
||
});
|
||
}
|
||
|
||
function hideContextMenu() {
|
||
document.getElementById('contextMenu').style.display = 'none';
|
||
document.getElementById('recentContextMenu').style.display = 'none';
|
||
}
|
||
|
||
function deleteSelectedRecentFiles() {
|
||
if (selectedRecentIds.size === 0) return;
|
||
let removedDone = 0, removedErr = 0;
|
||
sessionFilesData = sessionFilesData.filter(r => {
|
||
if (!selectedRecentIds.has(r.order)) return true;
|
||
if (r.isError) removedErr++; else removedDone++;
|
||
_sessionFileKeys.delete(`${r.link}\u0001${r.filename}\u0001${r.host}`);
|
||
return false;
|
||
});
|
||
_sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone);
|
||
_sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr);
|
||
selectedRecentIds.clear();
|
||
renderRecentUploadsPanel();
|
||
}
|
||
|
||
function clearAllRecentFiles() {
|
||
if (sessionFilesData.length === 0) return;
|
||
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
|
||
sessionFilesData = [];
|
||
_sessionFileKeys.clear();
|
||
_sessionDoneCount = 0;
|
||
_sessionErrorCount = 0;
|
||
selectedRecentIds.clear();
|
||
renderRecentUploadsPanel();
|
||
}
|
||
|
||
async function exportAllRecentFiles() {
|
||
if (sessionFilesData.length === 0) {
|
||
alert('Keine Einträge zum Exportieren.');
|
||
return;
|
||
}
|
||
const rows = sortRecentFiles(sessionFilesData);
|
||
const header = 'timestamp|hoster|link|filename|status';
|
||
const lines = rows.map(r => {
|
||
const ts = r.timestamp || r.time || '';
|
||
const host = r.host || r.hoster || '';
|
||
const link = r.link || '';
|
||
const name = r.filename || '';
|
||
const status = r.isError ? 'error' : 'ok';
|
||
return [ts, host, link, name, status].map(v => String(v).replace(/[\r\n|]/g, ' ')).join('|');
|
||
});
|
||
const content = [header, ...lines].join('\n') + '\n';
|
||
const defaultName = `uploads-${new Date().toISOString().slice(0, 10)}.log`;
|
||
try {
|
||
const result = await window.api.saveTextFile(defaultName, content, [
|
||
{ name: 'Log-Datei', extensions: ['log', 'txt', 'csv'] }
|
||
]);
|
||
if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`);
|
||
} catch (err) {
|
||
alert('Export fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
function copySelectedRecentLinks() {
|
||
const links = sessionFilesData
|
||
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
|
||
.map(r => r.link)
|
||
.filter(Boolean);
|
||
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
||
}
|
||
|
||
// --- Backup export / import ---
|
||
async function doBackupExport() {
|
||
try {
|
||
const result = await window.api.exportBackup();
|
||
if (result && result.ok) showCopyToast('Backup exportiert');
|
||
} catch (err) {
|
||
alert('Export fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
function askLegacyBackupPassword(hint) {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.style.display = 'flex';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'modal-card';
|
||
card.style.width = 'min(380px,100%)';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'modal-header';
|
||
const h3 = document.createElement('h3');
|
||
h3.textContent = 'Backup nicht entschlüsselbar';
|
||
header.appendChild(h3);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'modal-body';
|
||
const p = document.createElement('p');
|
||
p.style.margin = '0 0 10px';
|
||
p.style.fontSize = '13px';
|
||
p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
|
||
if (hint) {
|
||
const p2 = document.createElement('p');
|
||
p2.style.margin = '0 0 10px';
|
||
p2.style.fontSize = '12px';
|
||
p2.style.color = 'var(--text-dim)';
|
||
p2.textContent = hint;
|
||
body.appendChild(p2);
|
||
}
|
||
const input = document.createElement('input');
|
||
input.type = 'password';
|
||
input.className = 'key-input';
|
||
input.placeholder = 'Passwort';
|
||
input.autocomplete = 'off';
|
||
input.style.width = '100%';
|
||
body.appendChild(p);
|
||
body.appendChild(input);
|
||
|
||
const footer = document.createElement('div');
|
||
footer.className = 'modal-footer';
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.className = 'btn btn-secondary';
|
||
cancelBtn.textContent = 'Abbrechen';
|
||
const okBtn = document.createElement('button');
|
||
okBtn.className = 'btn btn-primary';
|
||
okBtn.textContent = 'Importieren';
|
||
footer.appendChild(cancelBtn);
|
||
footer.appendChild(okBtn);
|
||
|
||
card.appendChild(header);
|
||
card.appendChild(body);
|
||
card.appendChild(footer);
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
const done = (val) => { overlay.remove(); resolve(val); };
|
||
okBtn.onclick = () => done(input.value || null);
|
||
cancelBtn.onclick = () => done(null);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') done(input.value || null);
|
||
if (e.key === 'Escape') done(null);
|
||
});
|
||
input.focus();
|
||
});
|
||
}
|
||
|
||
async function doBackupImport(legacyPassword) {
|
||
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
|
||
try {
|
||
const result = await window.api.importBackup(pw);
|
||
if (!result || result.canceled) return;
|
||
if (result.needsPassword) {
|
||
const entered = await askLegacyBackupPassword(result.hint);
|
||
if (entered) doBackupImport(entered);
|
||
return;
|
||
}
|
||
if (result.ok) {
|
||
config = result.config;
|
||
hosterSettings = config.hosterSettings || {};
|
||
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
||
window.api.setAlwaysOnTop(alwaysOnTopState);
|
||
renderSettings();
|
||
renderAccounts();
|
||
renderHosterSummary();
|
||
renderHosterModal();
|
||
loadHistory();
|
||
showCopyToast('Backup importiert');
|
||
} else if (result.error) {
|
||
alert('Import fehlgeschlagen: ' + result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('Import fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.context-menu')) hideContextMenu();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
hideContextMenu();
|
||
cancelHosterModal();
|
||
}
|
||
if (e.target.closest('input, textarea, select')) return;
|
||
const activeView = document.querySelector('.view.active');
|
||
// Ctrl+A
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||
if (activeView && activeView.id === 'upload-view') {
|
||
e.preventDefault();
|
||
// Select recent files only if user's last interaction was in the recent panel
|
||
if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) {
|
||
sessionFilesData.forEach(r => selectedRecentIds.add(r.order));
|
||
renderRecentUploadsPanel();
|
||
} else if (queueJobs.length > 0) {
|
||
queueJobs.forEach(j => selectedJobIds.add(j.id));
|
||
renderQueueTable();
|
||
}
|
||
}
|
||
}
|
||
// Delete
|
||
if (e.key === 'Delete') {
|
||
if (activeView && activeView.id === 'upload-view') {
|
||
e.preventDefault();
|
||
if (selectedRecentIds.size > 0) {
|
||
deleteSelectedRecentFiles();
|
||
} else if (selectedJobIds.size > 0) {
|
||
const deletedIds = [...selectedJobIds];
|
||
// Cancel active uploads for deleted jobs
|
||
const activeIds = deletedIds.filter(id => {
|
||
const j = _jobIndexById.get(id);
|
||
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
|
||
});
|
||
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
|
||
queueJobs = queueJobs.filter(j => {
|
||
if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; }
|
||
return true;
|
||
});
|
||
selectedJobIds.clear();
|
||
syncSelectedFilesFromQueue();
|
||
renderQueueTable();
|
||
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('contextMenu').addEventListener('click', (e) => {
|
||
const item = e.target.closest('.ctx-item');
|
||
if (!item) return;
|
||
const action = item.dataset.action;
|
||
if (!action) return;
|
||
hideContextMenu();
|
||
handleContextAction(action);
|
||
});
|
||
|
||
async function handleContextAction(action) {
|
||
if (action === 'start-selected') {
|
||
startSelectedUpload();
|
||
} else if (action === 'copy-links') {
|
||
const links = getSelectedJobLinks();
|
||
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
||
} else if (action === 'retry-selected') {
|
||
retrySelectedJobs();
|
||
} else if (action === 'show-log') {
|
||
showJobLogModal();
|
||
} else if (action === 'delete-selected') {
|
||
// Cancel active uploads for deleted jobs
|
||
const activeIds = [...selectedJobIds].filter(id => {
|
||
const j = _jobIndexById.get(id);
|
||
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
|
||
});
|
||
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
|
||
queueJobs = queueJobs.filter(j => {
|
||
if (selectedJobIds.has(j.id)) {
|
||
removeJobFromIndex(j);
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
selectedJobIds.clear();
|
||
syncSelectedFilesFromQueue();
|
||
renderQueueTable();
|
||
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
} else if (action === 'copy-all-links') {
|
||
copyAllLinks();
|
||
} else if (action === 'delete-all') {
|
||
// Cancel all active uploads
|
||
const activeIds = queueJobs
|
||
.filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server')
|
||
.map(j => j.id);
|
||
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
|
||
queueJobs.forEach(j => removeJobFromIndex(j));
|
||
queueJobs = [];
|
||
selectedJobIds.clear();
|
||
selectedFiles = [];
|
||
syncSelectedFilesFromQueue();
|
||
renderQueueTable();
|
||
updateUploadView();
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
} else if (action === 'always-on-top') {
|
||
alwaysOnTopState = !alwaysOnTopState;
|
||
await window.api.setAlwaysOnTop(alwaysOnTopState);
|
||
} else if (action.startsWith('delete-hoster:')) {
|
||
const hoster = action.replace('delete-hoster:', '');
|
||
// Cancel active uploads for this hoster
|
||
const activeIds = queueJobs
|
||
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
|
||
.map(j => j.id);
|
||
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
|
||
// Remove ALL jobs for this hoster
|
||
queueJobs = queueJobs.filter(j => {
|
||
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
|
||
return true;
|
||
});
|
||
selectedJobIds.clear();
|
||
syncSelectedFilesFromQueue();
|
||
renderQueueTable();
|
||
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
||
updateStatusBar();
|
||
updateQueueActionButtons();
|
||
persistQueueStateSoon(true);
|
||
} else if (action.startsWith('shutdown-')) {
|
||
const mode = action.replace('shutdown-', '');
|
||
await window.api.setShutdownAfterFinish(mode);
|
||
}
|
||
}
|
||
|
||
function getSelectedJobLinks() {
|
||
return queueJobs
|
||
.filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result)
|
||
.map(j => j.result.download_url || j.result.embed_url || '')
|
||
.filter(Boolean);
|
||
}
|
||
|
||
// --- Upload ---
|
||
async function startUpload() {
|
||
if (uploading) return;
|
||
uploading = true; // set immediately to prevent double-click race
|
||
updateQueueActionButtons();
|
||
|
||
const hosters = getSelectedHosters();
|
||
if (queueJobs.length === 0 && selectedFiles.length > 0) {
|
||
if (hosters.length === 0) {
|
||
alert('Bitte mindestens einen Hoster auswählen.');
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
return;
|
||
}
|
||
buildQueuePreview();
|
||
}
|
||
|
||
const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status));
|
||
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
|
||
|
||
try {
|
||
jobsToStart.forEach(j => {
|
||
j.status = 'queued';
|
||
j.error = null;
|
||
j.result = null;
|
||
j.bytesUploaded = 0;
|
||
j.speedKbs = 0;
|
||
j.elapsed = 0;
|
||
j.remaining = 0;
|
||
j.progress = 0;
|
||
j.uploadId = null;
|
||
});
|
||
updateQueueActionButtons();
|
||
renderQueueTable();
|
||
updateStatusBar();
|
||
|
||
const uploadPayload = {
|
||
hosters,
|
||
jobs: jobsToStart.map((job) => ({
|
||
id: job.id,
|
||
file: job.file,
|
||
fileName: job.fileName,
|
||
hoster: job.hoster
|
||
}))
|
||
};
|
||
const result = await window.api.startUpload(uploadPayload);
|
||
_markSkippedJobs(result);
|
||
persistQueueStateSoon();
|
||
|
||
if (result && result.error) {
|
||
alert(result.error);
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
}
|
||
} catch (err) {
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function _markSkippedJobs(result) {
|
||
if (!result || !Array.isArray(result.skippedJobs) || result.skippedJobs.length === 0) return;
|
||
for (const skipped of result.skippedJobs) {
|
||
const job = _jobIndexById.get(skipped.jobId);
|
||
if (job) {
|
||
job.status = 'error';
|
||
job.error = skipped.reason || 'Kein gültiger Account';
|
||
}
|
||
}
|
||
renderQueueTable();
|
||
}
|
||
|
||
async function startSelectedUpload() {
|
||
if (uploading) {
|
||
// Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch
|
||
// Upload-manager has duplicate protection (skips jobs already tracked)
|
||
const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status));
|
||
if (addable.length > 0) {
|
||
addable.forEach(j => {
|
||
j.status = 'queued'; j.error = null; j.result = null;
|
||
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
|
||
});
|
||
renderQueueTable();
|
||
let result = null;
|
||
try {
|
||
result = await window.api.addJobsToBatch({
|
||
jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
|
||
});
|
||
} catch (err) {
|
||
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`);
|
||
return;
|
||
}
|
||
|
||
// If the batch ended between UI-state and IPC call, start a fresh batch immediately
|
||
if (result && result.error === 'Kein Upload aktiv') {
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
await startSelectedUpload();
|
||
return;
|
||
}
|
||
_markSkippedJobs(result);
|
||
persistQueueStateSoon();
|
||
const added = Number(result && result.added) || 0;
|
||
// Use ASCII-only toast text here to avoid encoding artifacts on some systems.
|
||
const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0;
|
||
const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds)
|
||
? result.alreadyInBatchJobIds.length
|
||
: Math.max(0, addable.length - added - skipped);
|
||
const toastParts = [];
|
||
if (added > 0) toastParts.push(`${added} hinzugefuegt`);
|
||
if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`);
|
||
if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`);
|
||
if (result && result.error) {
|
||
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`);
|
||
} else if (toastParts.length > 0) {
|
||
showCopyToast(`Jobs: ${toastParts.join(', ')}`);
|
||
} else {
|
||
showCopyToast('Keine Jobs hinzugefuegt');
|
||
}
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
uploading = true; // set immediately to prevent double-click race
|
||
updateQueueActionButtons();
|
||
|
||
const hosters = getSelectedHosters();
|
||
const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status));
|
||
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
|
||
|
||
try {
|
||
jobsToStart.forEach(j => {
|
||
j.status = 'queued';
|
||
j.error = null;
|
||
j.result = null;
|
||
j.bytesUploaded = 0;
|
||
j.speedKbs = 0;
|
||
j.progress = 0;
|
||
j.uploadId = null;
|
||
});
|
||
updateQueueActionButtons();
|
||
renderQueueTable();
|
||
updateStatusBar();
|
||
|
||
const uploadPayload = {
|
||
hosters,
|
||
jobs: jobsToStart.map((job) => ({
|
||
id: job.id,
|
||
file: job.file,
|
||
fileName: job.fileName,
|
||
hoster: job.hoster
|
||
}))
|
||
};
|
||
const result = await window.api.startUpload(uploadPayload);
|
||
_markSkippedJobs(result);
|
||
persistQueueStateSoon();
|
||
|
||
if (result && result.error) {
|
||
alert(result.error);
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
}
|
||
} catch (err) {
|
||
uploading = false;
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function cancelUpload() {
|
||
await window.api.cancelUpload();
|
||
uploading = false;
|
||
// Reset all non-finished jobs back to queued state
|
||
for (const job of queueJobs) {
|
||
if (!['done', 'error', 'skipped'].includes(job.status)) {
|
||
job.status = 'queued';
|
||
job.progress = 0;
|
||
job.bytesUploaded = 0;
|
||
job.speedKbs = 0;
|
||
job.elapsed = 0;
|
||
job.remaining = 0;
|
||
job.error = null;
|
||
}
|
||
}
|
||
renderQueueTable();
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
persistQueueStateSoon();
|
||
}
|
||
|
||
// --- Progress handling ---
|
||
function handleProgress(data) {
|
||
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
|
||
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
|
||
if (!job) {
|
||
job = queueJobs.find(j =>
|
||
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
|
||
) || queueJobs.find(j =>
|
||
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
|
||
);
|
||
if (job && data.uploadId) {
|
||
job.uploadId = data.uploadId;
|
||
_jobIndexByUploadId.set(data.uploadId, job);
|
||
}
|
||
}
|
||
if (!job) {
|
||
// Don't re-create jobs that were explicitly deleted by the user
|
||
if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) {
|
||
return;
|
||
}
|
||
job = {
|
||
id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
uploadId: data.uploadId,
|
||
file: '', fileName: data.fileName, hoster: data.hoster,
|
||
status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0,
|
||
speedKbs: 0, elapsed: 0, remaining: 0,
|
||
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
|
||
};
|
||
queueJobs.push(job);
|
||
indexJob(job);
|
||
}
|
||
|
||
// Don't regress from terminal states (stale callbacks can arrive after completion)
|
||
if (job.status === 'done' || job.status === 'skipped') return;
|
||
|
||
// Update job state
|
||
job.status = data.status;
|
||
job.bytesUploaded = data.bytesUploaded || 0;
|
||
job.bytesTotal = data.bytesTotal || job.bytesTotal;
|
||
// Track session total bytes (survives removeFromQueueOnDone)
|
||
if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) {
|
||
_sessionTotalBytes += job.bytesTotal;
|
||
_sessionTrackedJobs.add(job.id);
|
||
}
|
||
job.speedKbs = data.speedKbs || 0;
|
||
job.elapsed = data.elapsed || 0;
|
||
job.remaining = data.remaining || 0;
|
||
job.error = data.error || null;
|
||
job.result = data.result || job.result;
|
||
job.attempt = data.attempt || 0;
|
||
job.maxAttempts = data.maxAttempts || 0;
|
||
job.progress = data.progress || 0;
|
||
// Track which account the backend is currently using so the status cell
|
||
// can display "Primär" vs "Fallback #N" during rotation.
|
||
if (data.accountId) job.accountId = data.accountId;
|
||
if (data.uploadId) {
|
||
job.uploadId = data.uploadId;
|
||
_jobIndexByUploadId.set(data.uploadId, job);
|
||
}
|
||
|
||
maybeAddSessionFile(job);
|
||
|
||
// Track session uploaded bytes (survives removeFromQueueOnDone)
|
||
if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) {
|
||
_sessionUploadedBytes += job.bytesTotal || 0;
|
||
_sessionDoneJobs.add(job.id);
|
||
}
|
||
|
||
// Track completed uploads so they don't get re-queued after removal
|
||
if (job.status === 'done') {
|
||
_completedUploadKeys.add(`${job.file}|${job.hoster}`);
|
||
}
|
||
|
||
// Remove finished jobs from queue if setting is enabled. Coalesce the
|
||
// actual array filter into one microtask: a burst of 500 done events
|
||
// would otherwise fire 500 individual O(N) filters = O(N²) work, visible
|
||
// as a brief UI freeze when a big batch finishes. Index/selection are
|
||
// updated synchronously so subsequent lookups see the right state — only
|
||
// the array rewrite is deferred.
|
||
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
|
||
removeJobFromIndex(job);
|
||
selectedJobIds.delete(job.id);
|
||
if (_doneRemovalCoalescer) {
|
||
_doneRemovalCoalescer.add(job.id);
|
||
} else {
|
||
// Legacy slow path: immediate filter when the lib script didn't load.
|
||
queueJobs = queueJobs.filter(j => j !== job);
|
||
}
|
||
}
|
||
|
||
// Status changes (done/error/etc) get one coalesced update per frame so a
|
||
// burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM
|
||
// updates. Ongoing uploading progress is throttled at 200ms.
|
||
if (data.status === 'uploading') {
|
||
scheduleThrottledUIUpdate();
|
||
} else {
|
||
scheduleStatusChangeUpdate();
|
||
}
|
||
persistQueueStateSoon();
|
||
}
|
||
|
||
function handleBatchDone(summary) {
|
||
uploading = false;
|
||
applySummaryResults(summary);
|
||
_deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes
|
||
// Prune session-stats sets to current queue contents. Without this, IDs
|
||
// of jobs that were removed from queueJobs (via removeFromQueueOnDone
|
||
// or the cap-prune below) live forever in these sets — small leak per
|
||
// entry, real over weeks of use. _completedUploadKeys is intentionally
|
||
// kept (it's the dedup against re-queueing the same file).
|
||
if (_sessionTrackedJobs.size > 0 || _sessionDoneJobs.size > 0) {
|
||
const aliveIds = new Set();
|
||
for (const j of queueJobs) aliveIds.add(j.id);
|
||
for (const id of _sessionTrackedJobs) if (!aliveIds.has(id)) _sessionTrackedJobs.delete(id);
|
||
for (const id of _sessionDoneJobs) if (!aliveIds.has(id)) _sessionDoneJobs.delete(id);
|
||
}
|
||
|
||
// Reset aborted jobs back to queued so they can be restarted
|
||
for (const job of queueJobs) {
|
||
if (job.status === 'aborted') {
|
||
job.status = 'queued';
|
||
job.progress = 0;
|
||
job.bytesUploaded = 0;
|
||
job.speedKbs = 0;
|
||
job.elapsed = 0;
|
||
job.remaining = 0;
|
||
job.error = null;
|
||
}
|
||
}
|
||
|
||
syncSelectedFilesFromQueue();
|
||
updateQueueActionButtons();
|
||
renderQueueTable();
|
||
renderRecentUploadsPanel();
|
||
// History is only visible on the Verlauf tab. Mark it dirty and refresh when
|
||
// the user actually switches to it — skips an IPC + full table rebuild per
|
||
// batch-done when the user is watching the upload view.
|
||
_historyDirty = true;
|
||
if (_isHistoryTabActive()) loadHistory();
|
||
|
||
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
|
||
if (removeOnDone) {
|
||
// Single pass: build the keep-list and clean up the index for removed jobs.
|
||
const nextJobs = [];
|
||
for (const job of queueJobs) {
|
||
if (job.status === 'done') {
|
||
removeJobFromIndex(job);
|
||
selectedJobIds.delete(job.id);
|
||
} else {
|
||
nextJobs.push(job);
|
||
}
|
||
}
|
||
queueJobs = nextJobs;
|
||
renderQueueTable();
|
||
} else {
|
||
// Auto-prune for the default (removeOnDone=false) too: cap terminal
|
||
// jobs (done/skipped/error/aborted) at the most recent N so the queue
|
||
// can't grow unbounded across long sessions. The algorithm lives in
|
||
// lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js)
|
||
// and the result tells us which jobs to drop so we can clean up the
|
||
// index + selection in one pass.
|
||
const TERMINAL_KEEP_LIMIT = 500;
|
||
// Optional-chain so the renderer still works if the prune script fails
|
||
// to load (e.g. file:// path issues during dev) — falls back to no-prune
|
||
// rather than crashing on every batch-done.
|
||
const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT);
|
||
if (result) {
|
||
for (const j of result.dropped) {
|
||
removeJobFromIndex(j);
|
||
selectedJobIds.delete(j.id);
|
||
}
|
||
queueJobs = result.kept;
|
||
renderQueueTable();
|
||
}
|
||
}
|
||
|
||
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
|
||
else clearPersistedQueueStateSoon();
|
||
|
||
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
|
||
updateStatusBar();
|
||
_maybeShowBatchSummary(summary);
|
||
_refreshSessionFailedSnapshot();
|
||
}
|
||
|
||
let _sessionFailedKeys = new Set();
|
||
async function _refreshSessionFailedSnapshot() {
|
||
if (!window.api || !window.api.getSessionFailedAccounts) return;
|
||
try {
|
||
const keys = await window.api.getSessionFailedAccounts();
|
||
_sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []);
|
||
renderAccounts();
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function _maybeShowBatchSummary(summary) {
|
||
if (!window.Stats || !summary) return;
|
||
const buckets = window.Stats.summarizeBatchErrors(summary);
|
||
const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0);
|
||
if (total === 0) return;
|
||
|
||
const modal = document.getElementById('batchSummaryModal');
|
||
if (!modal) return;
|
||
const list = modal.querySelector('#batchSummaryList');
|
||
const retryAllBtn = modal.querySelector('#batchSummaryRetryAll');
|
||
const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient');
|
||
const closeBtn = modal.querySelector('#batchSummaryClose');
|
||
|
||
const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted'];
|
||
list.innerHTML = order
|
||
.filter(cat => buckets[cat].length > 0)
|
||
.map(cat => {
|
||
const items = buckets[cat];
|
||
const sample = items.slice(0, 3).map(i => `<li>${escapeHtml(i.fileName)} → ${escapeHtml(i.hoster)}: <em>${escapeHtml(i.error)}</em></li>`).join('');
|
||
const more = items.length > 3 ? `<li><em>… +${items.length - 3} weitere</em></li>` : '';
|
||
const retryable = window.Stats.isRetryableCategory(cat);
|
||
const tag = retryable ? '<span class="batch-cat-tag retryable">erneut versuchbar</span>' : '<span class="batch-cat-tag">manuell</span>';
|
||
return `<div class="batch-cat" data-category="${escapeAttr(cat)}">
|
||
<div class="batch-cat-head"><strong>${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)}</strong> <span class="batch-cat-count">${items.length}</span> ${tag}</div>
|
||
<ul class="batch-cat-list">${sample}${more}</ul>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0);
|
||
retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler';
|
||
retryTransientBtn.disabled = transientCount === 0;
|
||
const allRetryable = total - buckets['aborted'].length;
|
||
retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`;
|
||
retryAllBtn.disabled = allRetryable === 0;
|
||
|
||
const close = () => { modal.style.display = 'none'; };
|
||
closeBtn.onclick = close;
|
||
retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); };
|
||
retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); };
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function _retryFailedFromBuckets(buckets, transientOnly) {
|
||
const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error'];
|
||
const toRetry = [];
|
||
for (const cat of cats) {
|
||
for (const item of (buckets[cat] || [])) toRetry.push(item);
|
||
}
|
||
if (toRetry.length === 0) return;
|
||
const jobsToRetry = [];
|
||
for (const item of toRetry) {
|
||
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped'));
|
||
if (job) {
|
||
job.status = 'queued';
|
||
job.progress = 0;
|
||
job.bytesUploaded = 0;
|
||
job.error = null;
|
||
job.result = null;
|
||
jobsToRetry.push(job);
|
||
}
|
||
}
|
||
if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; }
|
||
renderQueueTable();
|
||
showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`);
|
||
if (typeof startUpload === 'function') startUpload();
|
||
}
|
||
|
||
function handleStats(data) {
|
||
lastUploadStats = {
|
||
state: data.state || 'idle',
|
||
globalSpeedKbs: data.globalSpeedKbs || 0,
|
||
totalBytes: data.totalBytes || 0,
|
||
elapsed: data.elapsed || 0,
|
||
activeJobs: data.activeJobs || 0
|
||
};
|
||
updateStatusBar();
|
||
updateStatsPanel();
|
||
|
||
// Track run time
|
||
if (data.state === 'uploading' || data.state === 'stopping') {
|
||
if (!statsStartTime) {
|
||
statsStartTime = Date.now();
|
||
statsRunTimer = setInterval(() => {
|
||
const el = document.getElementById('statRunTime');
|
||
if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000));
|
||
}, 1000);
|
||
}
|
||
} else if (data.state === 'idle' && statsRunTimer) {
|
||
clearInterval(statsRunTimer);
|
||
statsRunTimer = null;
|
||
}
|
||
}
|
||
|
||
// --- Per-job log modal ---
|
||
async function showJobLogModal() {
|
||
if (selectedJobIds.size === 0) return;
|
||
// Use the first selected job — log view is per-file, multi-select doesn't
|
||
// make sense here.
|
||
const jobId = [...selectedJobIds][0];
|
||
const job = _jobIndexById.get(jobId);
|
||
const modal = document.getElementById('jobLogModal');
|
||
const titleEl = document.getElementById('jobLogTitle');
|
||
const bodyEl = document.getElementById('jobLogBody');
|
||
if (!modal || !titleEl || !bodyEl) return;
|
||
|
||
titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log';
|
||
bodyEl.textContent = 'Lade…';
|
||
modal.style.display = 'flex';
|
||
|
||
let entries = [];
|
||
try { entries = await window.api.getJobLog(jobId); } catch {}
|
||
|
||
if (!Array.isArray(entries) || entries.length === 0) {
|
||
bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).';
|
||
return;
|
||
}
|
||
|
||
const fmt = (e) => {
|
||
const t = new Date(e.ts || Date.now()).toLocaleTimeString('de-DE', { hour12: false }) + '.' +
|
||
String((e.ts || 0) % 1000).padStart(3, '0');
|
||
if (e.kind === 'progress') {
|
||
const attempt = e.attempt ? ` (${e.attempt}/${e.maxAttempts || '?'})` : '';
|
||
const acc = e.accountId ? ` acc=${e.accountId.slice(0, 32)}` : '';
|
||
const err = e.error ? `\n → ${e.error}` : '';
|
||
return `[${t}] status=${e.status}${attempt}${acc}${err}`;
|
||
}
|
||
// rot-log
|
||
const rest = Object.entries(e)
|
||
.filter(([k]) => !['ts', 'kind', 'event', 'jobId'].includes(k))
|
||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||
.join(' ');
|
||
return `[${t}] [${e.event}] ${rest}`;
|
||
};
|
||
bodyEl.textContent = entries.map(fmt).join('\n');
|
||
}
|
||
|
||
function hideJobLogModal() {
|
||
const m = document.getElementById('jobLogModal');
|
||
if (m) m.style.display = 'none';
|
||
}
|
||
|
||
async function copyJobLogToClipboard() {
|
||
const body = document.getElementById('jobLogBody');
|
||
if (!body || !body.textContent) return;
|
||
try { await window.api.copyToClipboard(body.textContent); showCopyToast('Log in Zwischenablage'); } catch {}
|
||
}
|
||
|
||
// --- Retry ---
|
||
async function retrySelectedJobs() {
|
||
const retryJobs = [];
|
||
// Build a Set for O(1) selectedFiles dedup below.
|
||
const existingFilePaths = new Set();
|
||
for (const f of selectedFiles) existingFilePaths.add(f.path);
|
||
|
||
queueJobs.forEach(j => {
|
||
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
|
||
// Invalidate the old uploadId: retire the index entry and mark it so
|
||
// any late progress event from the previous (cancelled/completed)
|
||
// upload can't overwrite the freshly-reset state.
|
||
if (j.uploadId) {
|
||
_jobIndexByUploadId.delete(j.uploadId);
|
||
_deletedJobIds.add(j.uploadId);
|
||
}
|
||
j.status = uploading ? 'queued' : 'preview';
|
||
j.error = null;
|
||
j.result = null;
|
||
j.bytesUploaded = 0;
|
||
j.speedKbs = 0;
|
||
j.elapsed = 0;
|
||
j.remaining = 0;
|
||
j.progress = 0;
|
||
j.uploadId = null;
|
||
retryJobs.push(j);
|
||
if (!existingFilePaths.has(j.file)) {
|
||
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
|
||
existingFilePaths.add(j.file);
|
||
}
|
||
}
|
||
});
|
||
if (retryJobs.length === 0) return;
|
||
|
||
// Select the retry jobs and start them immediately.
|
||
// No renderQueueTable / updateQueueActionButtons / updateStatusBar here:
|
||
// startSelectedUpload() runs the exact same trio right after, and at 500+
|
||
// jobs the double render freezes the UI for multiple seconds.
|
||
selectedJobIds.clear();
|
||
retryJobs.forEach(j => selectedJobIds.add(j.id));
|
||
persistQueueStateSoon();
|
||
await startSelectedUpload();
|
||
}
|
||
|
||
async function abortSelectedJobs() {
|
||
const activeJobIds = [];
|
||
|
||
queueJobs.forEach((job) => {
|
||
if (!selectedJobIds.has(job.id)) return;
|
||
|
||
if (['preview', 'queued'].includes(job.status)) {
|
||
job.status = 'aborted';
|
||
job.error = 'Abgebrochen';
|
||
job.progress = 0;
|
||
job.uploadId = null;
|
||
} else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) {
|
||
activeJobIds.push(job.id);
|
||
}
|
||
});
|
||
|
||
if (activeJobIds.length > 0) {
|
||
await window.api.cancelSelectedJobs(activeJobIds);
|
||
}
|
||
|
||
selectedJobIds.clear();
|
||
syncSelectedFilesFromQueue();
|
||
renderQueueTable();
|
||
updateQueueActionButtons();
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
}
|
||
|
||
async function finishUploadsInProgress() {
|
||
if (!uploading) return;
|
||
await window.api.finishAfterActive();
|
||
lastUploadStats.state = 'stopping';
|
||
updateStatusBar();
|
||
}
|
||
|
||
async function abortAllUploads() {
|
||
await cancelUpload();
|
||
}
|
||
|
||
function moveSelectedJobs(direction) {
|
||
if (uploading || selectedJobIds.size === 0) return;
|
||
|
||
const jobs = queueJobs.slice();
|
||
|
||
if (direction === 'top') {
|
||
queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id)));
|
||
} else if (direction === 'bottom') {
|
||
queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id)));
|
||
} else if (direction === 'up') {
|
||
for (let i = 1; i < jobs.length; i++) {
|
||
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) {
|
||
[jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]];
|
||
}
|
||
}
|
||
queueJobs = jobs;
|
||
} else if (direction === 'down') {
|
||
for (let i = jobs.length - 2; i >= 0; i--) {
|
||
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) {
|
||
[jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]];
|
||
}
|
||
}
|
||
queueJobs = jobs;
|
||
}
|
||
|
||
rebuildJobIndex();
|
||
renderQueueTable();
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
}
|
||
|
||
function syncSelectedFilesFromQueue() {
|
||
const fileMap = new Map();
|
||
queueJobs
|
||
.filter((job) => !['done', 'skipped', 'aborted'].includes(job.status))
|
||
.forEach((job) => {
|
||
if (!job.file || fileMap.has(job.file)) return;
|
||
fileMap.set(job.file, {
|
||
path: job.file,
|
||
name: job.fileName,
|
||
size: job.bytesTotal || 0
|
||
});
|
||
});
|
||
selectedFiles = Array.from(fileMap.values());
|
||
}
|
||
|
||
// Cap recent-files panel growth so a multi-thousand-job session doesn't
|
||
// turn every renderRecentUploadsPanel call into a multi-MB innerHTML write.
|
||
const SESSION_FILES_CAP = 2000;
|
||
|
||
function maybeAddSessionFile(job) {
|
||
if (!job) return;
|
||
|
||
const dt = formatDateTime(new Date());
|
||
if (job.status === 'done' && job.result) {
|
||
const link = job.result.download_url || job.result.embed_url || '';
|
||
if (!link) return;
|
||
const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`;
|
||
if (!_sessionFileKeys.has(dedupKey)) {
|
||
_sessionFileKeys.add(dedupKey);
|
||
sessionFilesData.push({
|
||
date: dt.text,
|
||
dateTs: dt.ts,
|
||
filename: job.fileName || '',
|
||
host: job.hoster || '',
|
||
link,
|
||
isError: false,
|
||
order: sessionFilesData.length
|
||
});
|
||
_sessionDoneCount++;
|
||
// Drop oldest entries past the cap to keep render cost bounded.
|
||
// Without this, sessionFilesData grows unbounded across the session
|
||
// and every renderRecentUploadsPanel call becomes a megabyte-sized
|
||
// innerHTML write — visible as scroll/click lag in the lower panel.
|
||
if (sessionFilesData.length > SESSION_FILES_CAP) {
|
||
const drop = sessionFilesData.length - SESSION_FILES_CAP;
|
||
for (let i = 0; i < drop; i++) {
|
||
const r = sessionFilesData[i];
|
||
_sessionFileKeys.delete(`${r.link}${r.filename}${r.host}`);
|
||
}
|
||
sessionFilesData = sessionFilesData.slice(drop);
|
||
}
|
||
// Coalesce rapid successive adds into one render per frame.
|
||
scheduleRecentRender();
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
function applySummaryResults(summary) {
|
||
const files = Array.isArray(summary?.files) ? summary.files : [];
|
||
// Build a (fileName + hoster) → job map once so the per-result lookup is O(1)
|
||
// instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters)
|
||
// otherwise become O(n²).
|
||
const jobByKey = new Map();
|
||
for (const j of queueJobs) {
|
||
jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j);
|
||
}
|
||
for (const file of files) {
|
||
for (const result of file.results || []) {
|
||
const job = jobByKey.get(`${file.name}\u0001${result.hoster}`);
|
||
if (!job) continue;
|
||
if (result.status === 'done') {
|
||
job.status = 'done';
|
||
job.result = {
|
||
download_url: result.download_url || null,
|
||
embed_url: result.embed_url || null,
|
||
file_code: result.file_code || null
|
||
};
|
||
job.error = null;
|
||
job.progress = 1;
|
||
job.bytesUploaded = job.bytesTotal || file.size || 0;
|
||
} else if (result.status === 'aborted') {
|
||
job.status = 'aborted';
|
||
job.error = result.error || 'Abgebrochen';
|
||
} else if (result.status === 'error') {
|
||
job.status = 'error';
|
||
job.error = result.error || 'Fehlgeschlagen';
|
||
}
|
||
maybeAddSessionFile(job);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Single-pass queue stats computation (shared by status bar + stats panel).
|
||
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
|
||
//
|
||
// Memoized within a single tick: back-to-back calls (updateStatusBar +
|
||
// updateStatsPanel fire together 4×/sec during upload) share one scan. The
|
||
// cache is cleared on microtask so the next tick picks up fresh state.
|
||
let _queueStatsCache = null;
|
||
function _computeQueueStats() {
|
||
if (_queueStatsCache) return _queueStatsCache;
|
||
|
||
let remaining = 0, inProgress = 0, done = 0, errors = 0;
|
||
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
|
||
const total = queueJobs.length;
|
||
|
||
for (let i = 0; i < total; i++) {
|
||
const job = queueJobs[i];
|
||
const s = job.status;
|
||
const bt = job.bytesTotal || 0;
|
||
const bu = job.bytesUploaded || 0;
|
||
totalSize += bt;
|
||
|
||
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
|
||
inProgress++;
|
||
remaining++;
|
||
inProgressBytes += bu;
|
||
bytesRemaining += Math.max(0, bt - bu);
|
||
remainingSize += Math.max(0, bt - bu);
|
||
} else if (s === 'preview' || s === 'queued') {
|
||
remaining++;
|
||
bytesRemaining += Math.max(0, bt - bu);
|
||
remainingSize += Math.max(0, bt - bu);
|
||
} else if (s === 'done') {
|
||
done++;
|
||
} else if (s === 'error') {
|
||
errors++;
|
||
} else if (s !== 'skipped') {
|
||
remainingSize += Math.max(0, bt - bu);
|
||
}
|
||
}
|
||
|
||
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
|
||
queueMicrotask(() => { _queueStatsCache = null; });
|
||
return _queueStatsCache;
|
||
}
|
||
|
||
function updateStatusBar() {
|
||
const stats = _computeQueueStats();
|
||
|
||
const etaSeconds = lastUploadStats.globalSpeedKbs > 0
|
||
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
|
||
: 0;
|
||
|
||
const stateText = lastUploadStats.state === 'uploading'
|
||
? 'Upload läuft...'
|
||
: lastUploadStats.state === 'stopping'
|
||
? 'Stoppt nach aktiven Uploads...'
|
||
: uploading
|
||
? 'Upload vorbereitet...'
|
||
: 'Bereit';
|
||
|
||
document.getElementById('sbState').textContent = stateText;
|
||
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
|
||
const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes;
|
||
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
|
||
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
|
||
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
|
||
document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`;
|
||
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
|
||
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
|
||
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
|
||
document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
|
||
document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
|
||
}
|
||
|
||
// --- Health Check ---
|
||
|
||
function renderHealthCheckResults(_results) {
|
||
const container = document.getElementById('healthCheckResults');
|
||
if (container) container.innerHTML = '';
|
||
}
|
||
|
||
async function executeHealthCheck(hosters, _mode) {
|
||
renderHealthCheckResults([]);
|
||
const result = await window.api.runHealthCheck({ hosters });
|
||
const rows = result && Array.isArray(result.results) ? result.results : [];
|
||
rows.forEach((row) => {
|
||
if (!row) return;
|
||
const key = row.accountId || row.hoster;
|
||
if (key) {
|
||
accountStatuses[key] = {
|
||
status: row.status || 'unchecked',
|
||
message: row.message || ''
|
||
};
|
||
}
|
||
});
|
||
renderHealthCheckResults(rows);
|
||
renderAccounts();
|
||
renderHosterModal();
|
||
return rows;
|
||
}
|
||
|
||
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
||
if (healthCheckRunning) {
|
||
if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
|
||
return [];
|
||
}
|
||
let hosters;
|
||
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
|
||
hosters = requestedHosters;
|
||
} else {
|
||
hosters = getAccountsWithCredsFlat()
|
||
.filter(({ account }) => account.enabled !== false)
|
||
.map(({ name, account }) => ({ hoster: name, accountId: account.id }));
|
||
}
|
||
if (hosters.length === 0) {
|
||
if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.');
|
||
return [];
|
||
}
|
||
healthCheckRunning = true;
|
||
// Mark all accounts as checking
|
||
for (const h of hosters) {
|
||
const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
|
||
accountStatuses[key] = { status: 'checking', message: '' };
|
||
}
|
||
renderAccounts();
|
||
try {
|
||
return await executeHealthCheck(hosters, mode);
|
||
} catch (err) {
|
||
renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]);
|
||
return [];
|
||
} finally {
|
||
healthCheckRunning = false;
|
||
renderAccounts();
|
||
}
|
||
}
|
||
|
||
// --- Settings ---
|
||
async function _renderLogPathsList(el) {
|
||
if (!el || !window.api || !window.api.getLogPaths) return;
|
||
try {
|
||
const paths = await window.api.getLogPaths();
|
||
if (!paths || typeof paths !== 'object') { el.innerHTML = '<span class="hint">Pfade nicht verfügbar.</span>'; return; }
|
||
const entries = [
|
||
['fileuploader', 'fileuploader.log'],
|
||
['debug', 'debug.log'],
|
||
['accountRotation', 'account-rotation.log'],
|
||
['doodstreamDebug', 'doodstream-debug.log']
|
||
];
|
||
el.innerHTML = entries.map(([key, label]) => {
|
||
const p = paths[key] || '';
|
||
return `<div style="display:flex;gap:6px;align-items:center;font-size:11px">
|
||
<span style="min-width:160px;color:var(--text-dim)">${escapeHtml(label)}</span>
|
||
<code style="flex:1;font-size:10px;opacity:0.85;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeAttr(p)}">${escapeHtml(p) || '<nicht gesetzt>'}</code>
|
||
<button class="btn btn-xs btn-secondary" data-reveal-log="${escapeAttr(key)}" title="Im Explorer zeigen">Zeigen</button>
|
||
</div>`;
|
||
}).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 = `<span class="hint">Fehler: ${escapeHtml(err.message || String(err))}</span>`;
|
||
}
|
||
}
|
||
|
||
function renderSettings() {
|
||
const container = document.getElementById('settingsHosters');
|
||
container.innerHTML = '';
|
||
|
||
const globalSettings = config.globalSettings || {};
|
||
const configuredAccounts = getAvailableHosters();
|
||
const generalPanel = document.createElement('div');
|
||
generalPanel.className = 'hoster-settings-panel';
|
||
generalPanel.innerHTML = `
|
||
<div class="hoster-panel-header" data-hoster="global">
|
||
<span class="panel-arrow">▼</span>
|
||
<span class="panel-title">Allgemein</span>
|
||
<span class="panel-status active">System</span>
|
||
</div>
|
||
<div class="hoster-panel-body" data-panel="global" style="display:block">
|
||
<div class="settings-section-label">Uploads</div>
|
||
<div class="settings-row">
|
||
<label style="min-width:185px">Globale parallele Uploads</label>
|
||
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100" style="width:80px">
|
||
<span class="hint">0 = nur pro Hoster</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label style="min-width:185px">Globales Speed-Limit (MB/s)</label>
|
||
<input type="number" class="hs-input settings-autosave" id="globalMaxSpeedMbsInput" value="${globalSettings.globalMaxSpeedKbs > 0 ? (globalSettings.globalMaxSpeedKbs / 1024).toFixed(2).replace(/\\.00$/, '') : '0'}" min="0" step="0.1" style="width:80px">
|
||
<span class="hint">0 = unbegrenzt</span>
|
||
</div>
|
||
<div class="settings-section-label">Verhalten</div>
|
||
<div class="settings-grid-mini">
|
||
<div class="settings-row checkbox-row">
|
||
<label>Immer im Vordergrund</label>
|
||
<input type="checkbox" class="settings-autosave" id="alwaysOnTopInput" ${alwaysOnTopState ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Hoster-Limits hochskalieren</label>
|
||
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Aus Queue entfernen bei Abschluss</label>
|
||
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Queue beim Start wiederherstellen</label>
|
||
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Drop-Target anzeigen</label>
|
||
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section-label">Updates</div>
|
||
<div class="settings-row">
|
||
<label>Manuell prüfen</label>
|
||
<button class="btn btn-xs btn-secondary" id="manualUpdateCheckBtn">Nach Updates suchen</button>
|
||
</div>
|
||
<div class="settings-section-label">Log</div>
|
||
<div class="settings-row">
|
||
<label>FileUploader Log</label>
|
||
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
|
||
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
|
||
<button class="btn btn-xs btn-secondary" id="openLogFolderBtn" title="Log-Ordner im Explorer öffnen">Öffnen</button>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Log-Datei-Modus</label>
|
||
<select class="hs-input settings-autosave" id="logModeInput">
|
||
<option value="single" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'single' ? 'selected' : ''}>Eine Datei</option>
|
||
<option value="daily" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'daily' ? 'selected' : ''}>Pro Tag</option>
|
||
<option value="session" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'session' ? 'selected' : ''}>Pro Session</option>
|
||
</select>
|
||
<span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Verbose Logging</label>
|
||
<label class="checkbox-row" style="margin:0">
|
||
<input type="checkbox" class="settings-autosave" id="logVerboseInput" ${globalSettings.logVerbose ? 'checked' : ''}>
|
||
<span>DEBUG-Einträge in debug.log schreiben (Performance ↓, Diagnostik ↑)</span>
|
||
</label>
|
||
</div>
|
||
<div class="settings-section-label">Diagnose</div>
|
||
<div class="settings-row" id="logPathsBlock">
|
||
<label>Log-Dateien</label>
|
||
<div class="log-paths-list" id="logPathsList" style="flex:1;display:flex;flex-direction:column;gap:4px">
|
||
<span class="hint">Wird geladen…</span>
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Support-Paket</label>
|
||
<button class="btn btn-xs btn-secondary" id="createSupportBundleBtn" title="Sammelt alle Logs + sanitierte Config (Credentials maskiert) + App-Versionen in eine einzelne .txt-Datei zum Teilen.">Diagnose-Paket exportieren</button>
|
||
<span class="hint" id="supportBundleHint">Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(generalPanel);
|
||
_renderLogPathsList(generalPanel.querySelector('#logPathsList'));
|
||
const verboseInput = generalPanel.querySelector('#logVerboseInput');
|
||
if (verboseInput) {
|
||
verboseInput.addEventListener('change', () => {
|
||
if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {});
|
||
});
|
||
}
|
||
const sbBtn = generalPanel.querySelector('#createSupportBundleBtn');
|
||
if (sbBtn) {
|
||
sbBtn.addEventListener('click', async () => {
|
||
const hint = generalPanel.querySelector('#supportBundleHint');
|
||
sbBtn.disabled = true;
|
||
const prevText = sbBtn.textContent;
|
||
sbBtn.textContent = 'Exportiere…';
|
||
try {
|
||
const res = await window.api.createSupportBundle();
|
||
if (res && res.ok) {
|
||
if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`;
|
||
} else if (res && res.canceled) {
|
||
if (hint) hint.textContent = 'Abgebrochen.';
|
||
} else {
|
||
if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`;
|
||
}
|
||
} catch (err) {
|
||
if (hint) hint.textContent = `Fehler: ${err.message || err}`;
|
||
} finally {
|
||
sbBtn.disabled = false;
|
||
sbBtn.textContent = prevText;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Toggle general panel
|
||
generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||
const body = generalPanel.querySelector('.hoster-panel-body');
|
||
const arrow = generalPanel.querySelector('.panel-arrow');
|
||
const isOpen = body.style.display !== 'none';
|
||
body.style.display = isOpen ? 'none' : 'block';
|
||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||
});
|
||
|
||
// --- Folder Monitor Panel ---
|
||
const fm = globalSettings.folderMonitor || {};
|
||
const folderMonitorPanel = document.createElement('div');
|
||
folderMonitorPanel.className = 'hoster-settings-panel';
|
||
folderMonitorPanel.innerHTML = `
|
||
<div class="hoster-panel-header" data-hoster="folderMonitor">
|
||
<span class="panel-arrow">▶</span>
|
||
<span class="panel-title">Ordnerüberwachung</span>
|
||
<span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span>
|
||
</div>
|
||
<div class="hoster-panel-body" data-panel="folderMonitor" style="display:none">
|
||
<div class="settings-section-label">Ordner</div>
|
||
<div class="settings-row">
|
||
<label>Ordnerpfad</label>
|
||
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen..." style="flex:1">
|
||
<button class="btn btn-xs btn-secondary" id="fmChooseFolderBtn">Wählen</button>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Dateierweiterungen</label>
|
||
<select class="hs-input settings-autosave" id="fmFilterModeInput" style="width:auto;margin-right:6px">
|
||
<option value="include" ${fm.filterMode === 'include' ? 'selected' : ''}>Nur diese</option>
|
||
<option value="exclude" ${fm.filterMode === 'exclude' ? 'selected' : ''}>Alle außer</option>
|
||
</select>
|
||
<input type="text" class="key-input settings-autosave" id="fmExtensionsInput" value="${escapeAttr(fm.extensions || '')}" placeholder="mp4,mkv,avi" style="flex:1">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Verzögerung (Sekunden)</label>
|
||
<input type="number" class="hs-input settings-autosave" id="fmDelaySecInput" value="${fm.delaySec ?? 3}" min="1" max="300" style="width:80px">
|
||
<span class="hint">Warten bis Datei fertig geschrieben</span>
|
||
</div>
|
||
<div class="settings-section-label">Verhalten</div>
|
||
<div class="settings-grid-mini">
|
||
<div class="settings-row checkbox-row">
|
||
<label>Aktiviert</label>
|
||
<input type="checkbox" class="settings-autosave" id="fmEnabledInput" ${fm.enabled ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Unterordner einbeziehen</label>
|
||
<input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Duplikate überspringen</label>
|
||
<input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Auto-Upload starten</label>
|
||
<input type="checkbox" class="settings-autosave" id="fmAutoStartInput" ${fm.autoStart !== false ? 'checked' : ''}>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section-label">Hoster-Vorauswahl</div>
|
||
<div class="settings-grid-mini">
|
||
${configuredAccounts.map(({ name }) => `
|
||
<div class="settings-row checkbox-row">
|
||
<label>${escapeHtml(name)}</label>
|
||
<input type="checkbox" class="settings-autosave fm-hoster-checkbox" data-fm-hoster="${name}" ${(fm.hosters || []).includes(name) ? 'checked' : ''}>
|
||
</div>`).join('')}
|
||
</div>
|
||
${configuredAccounts.length === 0 ? '<p class="hint" style="margin:0">Erst Accounts anlegen, dann hier auswählen.</p>' : '<p class="hint" style="margin:2px 0 0">Keine Auswahl = Hoster-Modal bei jeder Datei.</p>'}
|
||
</div>
|
||
`;
|
||
container.appendChild(folderMonitorPanel);
|
||
|
||
// Toggle folder monitor panel
|
||
folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||
const body = folderMonitorPanel.querySelector('.hoster-panel-body');
|
||
const arrow = folderMonitorPanel.querySelector('.panel-arrow');
|
||
const isOpen = body.style.display !== 'none';
|
||
body.style.display = isOpen ? 'none' : 'block';
|
||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||
});
|
||
|
||
// Update badge immediately on checkbox/path change
|
||
const updateFmBadge = () => {
|
||
const b = document.getElementById('folderMonitorStatusBadge');
|
||
if (!b) return;
|
||
const enabled = document.getElementById('fmEnabledInput')?.checked;
|
||
const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim();
|
||
if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; }
|
||
else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; }
|
||
};
|
||
document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge);
|
||
document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge);
|
||
|
||
document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => {
|
||
const folder = await window.api.folderMonitorSelectFolder();
|
||
if (folder) {
|
||
document.getElementById('fmFolderPathInput').value = folder;
|
||
updateFmBadge();
|
||
scheduleSettingsSave();
|
||
}
|
||
});
|
||
|
||
// --- Remote Control Panel ---
|
||
const remoteSettings = globalSettings.remote || {};
|
||
const remotePanel = document.createElement('div');
|
||
remotePanel.className = 'hoster-settings-panel';
|
||
remotePanel.innerHTML = `
|
||
<div class="hoster-panel-header" data-hoster="remote">
|
||
<span class="panel-arrow">▶</span>
|
||
<span class="panel-title">Fernsteuerung</span>
|
||
<span class="panel-status${remoteSettings.enabled ? ' active' : ''}" id="remoteStatusBadge">${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}</span>
|
||
</div>
|
||
<div class="hoster-panel-body" data-panel="remote" style="display:none">
|
||
<div class="settings-section-label">Server</div>
|
||
<div class="settings-grid-mini">
|
||
<div class="settings-row checkbox-row">
|
||
<label>Aktiviert</label>
|
||
<input type="checkbox" class="settings-autosave" id="remoteEnabledInput" ${remoteSettings.enabled ? 'checked' : ''}>
|
||
</div>
|
||
<div class="settings-row checkbox-row">
|
||
<label>Input erlauben</label>
|
||
<input type="checkbox" class="settings-autosave" id="remoteAllowInputInput" ${remoteSettings.allowInput !== false ? 'checked' : ''}>
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Port</label>
|
||
<input type="number" class="hs-input settings-autosave" id="remotePortInput" value="${remoteSettings.port || 9100}" min="1024" max="65535" style="width:100px">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>API-Token</label>
|
||
<input type="text" class="key-input" id="remoteTokenInput" value="${escapeAttr(remoteSettings.token || '')}" readonly style="flex:1">
|
||
<button class="btn btn-xs btn-secondary" id="remoteCopyTokenBtn" title="Kopieren">Kopieren</button>
|
||
<button class="btn btn-xs btn-secondary" id="remoteRegenerateTokenBtn" title="Neu generieren">Neu</button>
|
||
</div>
|
||
<div class="settings-section-label">Status</div>
|
||
<div class="settings-row">
|
||
<span id="remoteConnectionStatus" style="color:#94a3b8">Prüfe...</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(remotePanel);
|
||
|
||
// Toggle remote panel
|
||
remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||
const body = remotePanel.querySelector('.hoster-panel-body');
|
||
const arrow = remotePanel.querySelector('.panel-arrow');
|
||
const isOpen = body.style.display !== 'none';
|
||
body.style.display = isOpen ? 'none' : 'block';
|
||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||
});
|
||
|
||
// Copy token
|
||
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
|
||
const token = document.getElementById('remoteTokenInput').value;
|
||
if (token) {
|
||
await window.api.copyToClipboard(token);
|
||
document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!';
|
||
setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500);
|
||
}
|
||
});
|
||
|
||
// Regenerate token
|
||
document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => {
|
||
const newToken = await window.api.remoteGenerateToken();
|
||
document.getElementById('remoteTokenInput').value = newToken;
|
||
scheduleSettingsSave();
|
||
});
|
||
|
||
// Update status
|
||
window.api.remoteStatus().then(status => {
|
||
const el = document.getElementById('remoteConnectionStatus');
|
||
if (!el) return;
|
||
if (status.running) {
|
||
el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||
el.style.color = '#10b981';
|
||
} else {
|
||
el.textContent = 'Nicht aktiv';
|
||
el.style.color = '#94a3b8';
|
||
}
|
||
}).catch(() => {});
|
||
|
||
// Live client count updates (listener registered once in init, not here)
|
||
|
||
// --- Backup Panel ---
|
||
const backupPanel = document.createElement('div');
|
||
backupPanel.className = 'hoster-settings-panel';
|
||
backupPanel.innerHTML = `
|
||
<div class="hoster-panel-header" data-hoster="backup">
|
||
<span class="panel-arrow">▶</span>
|
||
<span class="panel-title">Backup</span>
|
||
<span class="panel-status active">System</span>
|
||
</div>
|
||
<div class="hoster-panel-body" data-panel="backup" style="display:none">
|
||
<p class="hint" style="margin:0 0 10px">Alle Accounts, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.</p>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
|
||
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(backupPanel);
|
||
|
||
backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||
const body = backupPanel.querySelector('.hoster-panel-body');
|
||
const arrow = backupPanel.querySelector('.panel-arrow');
|
||
const isOpen = body.style.display !== 'none';
|
||
body.style.display = isOpen ? 'none' : 'block';
|
||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||
});
|
||
|
||
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
||
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
||
|
||
// --- Separator before hoster panels ---
|
||
const separator = document.createElement('div');
|
||
separator.style.cssText = 'height:16px';
|
||
container.appendChild(separator);
|
||
|
||
if (configuredAccounts.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'settings-empty';
|
||
empty.innerHTML = '<p>Noch keine Account-Einstellungen vorhanden.</p><span class="hint">Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.</span>';
|
||
container.appendChild(empty);
|
||
}
|
||
|
||
for (const { name } of configuredAccounts) {
|
||
const hs = hosterSettings[name] || {};
|
||
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0';
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'hoster-settings-panel';
|
||
|
||
panel.innerHTML = `
|
||
<div class="hoster-panel-header" data-hoster="${name}">
|
||
<span class="panel-arrow">▶</span>
|
||
<span class="panel-title">${escapeHtml(getHosterLabel(name))}</span>
|
||
<span class="panel-status active">Aktiv</span>
|
||
</div>
|
||
<div class="hoster-panel-body" data-panel="${name}" style="display:none">
|
||
<h4>Upload-Einstellungen</h4>
|
||
<div class="settings-grid-mini">
|
||
<div class="settings-row">
|
||
<label>Retries</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Max Speed (MB/s)</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSpeedMbs" value="${maxSpeedMbs}" min="0" step="0.1">
|
||
<span class="hint">0 = unbegrenzt</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Parallele Uploads</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="parallelCount" value="${hs.parallelCount ?? 2}" min="1" max="100">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Restart unter (kB/s)</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="restartBelowKbs" value="${hs.restartBelowKbs ?? 0}" min="0">
|
||
<span class="hint">0 = aus</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Intervall (s)</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="timeIntervalSec" value="${hs.timeIntervalSec ?? 0}" min="0">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Max Size (MB)</label>
|
||
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSizeMb" value="${hs.maxSizeMb ?? 0}" min="0">
|
||
<span class="hint">0 = unbegrenzt</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Links in Log schreiben</label>
|
||
<input type="checkbox" class="hs-input settings-autosave" data-hoster="${name}" data-hs="logToFile" ${hs.logToFile !== false ? 'checked' : ''}>
|
||
<span class="hint">Erfolgreiche Links in fileuploader.log. Aus = auch kein Doppel-Upload-Schutz beim Neustart für diesen Hoster.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(panel);
|
||
|
||
// Toggle panel
|
||
panel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||
const body = panel.querySelector('.hoster-panel-body');
|
||
const arrow = panel.querySelector('.panel-arrow');
|
||
const isOpen = body.style.display !== 'none';
|
||
body.style.display = isOpen ? 'none' : 'block';
|
||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||
});
|
||
}
|
||
|
||
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
||
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
|
||
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => {
|
||
const btn = e.target;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Prüfe...';
|
||
try {
|
||
const result = await window.api.checkForUpdate();
|
||
if (result && result.available) {
|
||
showUpdateBanner(result);
|
||
btn.textContent = 'Update gefunden!';
|
||
} else {
|
||
btn.textContent = 'Kein Update verfügbar';
|
||
}
|
||
} catch {
|
||
btn.textContent = 'Fehler beim Prüfen';
|
||
}
|
||
setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000);
|
||
});
|
||
container.querySelectorAll('.settings-autosave').forEach((input) => {
|
||
const eventName = input.type === 'checkbox' ? 'change' : 'input';
|
||
input.addEventListener(eventName, scheduleSettingsSave);
|
||
});
|
||
}
|
||
|
||
async function chooseLogFilePath() {
|
||
const folders = await window.api.selectFolder();
|
||
if (!folders || !folders[0]) return;
|
||
const normalized = folders[0].replace(/[\\\/]+$/, '');
|
||
document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`;
|
||
scheduleSettingsSave();
|
||
}
|
||
|
||
function scheduleSettingsSave() {
|
||
const feedback = document.getElementById('saveFeedback');
|
||
if (feedback) feedback.textContent = 'Speichert...';
|
||
clearTimeout(settingsSaveTimer);
|
||
settingsSaveTimer = setTimeout(() => {
|
||
saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => {
|
||
if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`;
|
||
});
|
||
}, 350);
|
||
}
|
||
|
||
async function saveSettings(options = {}) {
|
||
const { feedbackText = 'Gespeichert!' } = options;
|
||
const newHosterSettings = { ...(config.hosterSettings || {}) };
|
||
const globalSettings = {
|
||
...(config.globalSettings || {}),
|
||
logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(),
|
||
logMode: (() => {
|
||
const v = document.getElementById('logModeInput')?.value;
|
||
return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single';
|
||
})(),
|
||
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
|
||
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
|
||
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
|
||
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked,
|
||
showDropTarget: !!document.getElementById('showDropTargetInput')?.checked,
|
||
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)),
|
||
folderMonitor: {
|
||
enabled: !!document.getElementById('fmEnabledInput')?.checked,
|
||
folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(),
|
||
recursive: !!document.getElementById('fmRecursiveInput')?.checked,
|
||
filterMode: document.getElementById('fmFilterModeInput')?.value || 'include',
|
||
extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(),
|
||
skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked,
|
||
delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3),
|
||
autoStart: !!document.getElementById('fmAutoStartInput')?.checked,
|
||
hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
|
||
},
|
||
remote: {
|
||
enabled: !!document.getElementById('remoteEnabledInput')?.checked,
|
||
port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)),
|
||
token: (document.getElementById('remoteTokenInput')?.value || '').trim(),
|
||
allowInput: !!document.getElementById('remoteAllowInputInput')?.checked
|
||
}
|
||
};
|
||
|
||
// Always on top setting
|
||
const aotCheckbox = document.getElementById('alwaysOnTopInput');
|
||
if (aotCheckbox) {
|
||
const newAot = !!aotCheckbox.checked;
|
||
if (newAot !== alwaysOnTopState) {
|
||
alwaysOnTopState = newAot;
|
||
await window.api.setAlwaysOnTop(alwaysOnTopState);
|
||
}
|
||
}
|
||
|
||
// Drop target window
|
||
const dtCheckbox = document.getElementById('showDropTargetInput');
|
||
if (dtCheckbox) {
|
||
if (dtCheckbox.checked) await window.api.showDropTarget();
|
||
else await window.api.hideDropTarget();
|
||
}
|
||
|
||
for (const name of HOSTERS) {
|
||
const hs = { ...(hosterSettings[name] || {}) };
|
||
document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => {
|
||
const field = input.dataset.hs;
|
||
if (input.type === 'checkbox') hs[field] = input.checked;
|
||
else if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024));
|
||
else hs[field] = parseInt(input.value, 10) || 0;
|
||
});
|
||
newHosterSettings[name] = hs;
|
||
}
|
||
|
||
// Fire both saves in parallel instead of serializing the two IPC round-trips.
|
||
// Skip the getConfig refetch — we just wrote it, we know the new state, and
|
||
// the round-trip added 100–200ms of UI stall per keystroke (autosave fires
|
||
// on every input change).
|
||
await Promise.all([
|
||
window.api.saveHosterSettings(newHosterSettings),
|
||
window.api.saveGlobalSettings(globalSettings)
|
||
]);
|
||
config.hosterSettings = newHosterSettings;
|
||
config.globalSettings = globalSettings;
|
||
hosterSettings = newHosterSettings;
|
||
clearTimeout(settingsSaveTimer);
|
||
|
||
// Start/stop folder monitor based on settings
|
||
const fmSettings = globalSettings.folderMonitor;
|
||
const badge = document.getElementById('folderMonitorStatusBadge');
|
||
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
|
||
try {
|
||
await window.api.folderMonitorStart(fmSettings);
|
||
if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; }
|
||
} catch {
|
||
if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; }
|
||
}
|
||
} else {
|
||
await window.api.folderMonitorStop();
|
||
if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; }
|
||
}
|
||
|
||
// Start/stop remote server based on settings
|
||
const remoteSettings = globalSettings.remote;
|
||
const remoteBadge = document.getElementById('remoteStatusBadge');
|
||
if (remoteSettings) {
|
||
try {
|
||
await window.api.remoteSaveSettings(remoteSettings);
|
||
if (remoteBadge) {
|
||
remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv';
|
||
remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`;
|
||
}
|
||
// Update status display
|
||
const status = await window.api.remoteStatus();
|
||
const statusEl = document.getElementById('remoteConnectionStatus');
|
||
if (statusEl) {
|
||
if (status.running) {
|
||
statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||
statusEl.style.color = '#10b981';
|
||
} else {
|
||
statusEl.textContent = 'Nicht aktiv';
|
||
statusEl.style.color = '#94a3b8';
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
const feedback = document.getElementById('saveFeedback');
|
||
feedback.textContent = feedbackText;
|
||
setTimeout(() => {
|
||
if (feedback.textContent === feedbackText) {
|
||
feedback.textContent = 'Änderungen werden automatisch gespeichert.';
|
||
}
|
||
}, 1800);
|
||
}
|
||
|
||
// --- Accounts ---
|
||
function getCredentialLabel(name, account) {
|
||
if (!account) return 'Keine Zugangsdaten';
|
||
if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`;
|
||
if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`;
|
||
// Fallback
|
||
if (account.username && account.password) return `Login: ${account.username}`;
|
||
if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`;
|
||
return 'Keine Zugangsdaten';
|
||
}
|
||
|
||
const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
|
||
|
||
function _buildAccountCardHtml(name, account, idx) {
|
||
const isDisabled = account.enabled === false;
|
||
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
|
||
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
|
||
const statusClass = isDisabled ? 'disabled' : st.status;
|
||
const credLabel = getCredentialLabel(name, account);
|
||
const userLabel = account.label && String(account.label).trim();
|
||
// Subtitle: "Label: XYZ • API: ABC… • <status>" — the user-set label is the
|
||
// disambiguator for accounts that otherwise look identical (e.g. two byse
|
||
// API-key accounts where you can't tell what's what from the masked key).
|
||
const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel;
|
||
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
|
||
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
|
||
|
||
const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`);
|
||
const sessionPausedBadge = isSessionPaused
|
||
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.">Pausiert (Session) <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren">↻</button></span>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="account-card${isDisabled ? ' account-disabled' : ''}${isSessionPaused ? ' account-session-paused-card' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
|
||
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">☰</div>
|
||
<div class="account-card-info">
|
||
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
|
||
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? ` • ${escapeHtml(st.message)}` : ''}</div>
|
||
</div>
|
||
<span class="account-status status-${statusClass}">
|
||
<span class="account-status-dot"></span>
|
||
${statusLabel}
|
||
</span>
|
||
<div class="account-card-actions">
|
||
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
|
||
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
|
||
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
|
||
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Replace only the one card for `accountId` instead of re-rendering the whole
|
||
// container. Runs on enable/disable, single health check, priority-badge bumps
|
||
// after a reorder — anywhere we only change one card's state.
|
||
function updateAccountCard(accountId) {
|
||
const container = document.getElementById('accountsList');
|
||
if (!container) return;
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`);
|
||
if (!card) return;
|
||
const accounts = config.hosters[found.name] || [];
|
||
const idx = accounts.findIndex(a => a.id === accountId);
|
||
if (idx < 0) return;
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
|
||
card.replaceWith(tmp.firstElementChild);
|
||
_refreshHosterGroupHeader(found.name);
|
||
}
|
||
|
||
function _refreshHosterGroupHeader(name) {
|
||
const container = document.getElementById('accountsList');
|
||
if (!container) return;
|
||
const group = container.querySelector(`.account-hoster-group[data-hoster-group="${name}"]`);
|
||
if (!group) return;
|
||
const accounts = config.hosters[name] || [];
|
||
const summary = _summarizeHosterGroup(accounts);
|
||
let dot = 'unchecked';
|
||
if (summary.error > 0) dot = 'error';
|
||
else if (summary.checking > 0) dot = 'checking';
|
||
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
|
||
const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot');
|
||
if (dotEl) dotEl.className = `account-status-dot status-${dot}`;
|
||
const countEl = group.querySelector('.account-hoster-group-count');
|
||
if (countEl) countEl.textContent = `${summary.ok}/${summary.total}`;
|
||
group.querySelectorAll('.account-hoster-group-meta').forEach(el => el.remove());
|
||
const header = group.querySelector('.account-hoster-group-header');
|
||
if (header) {
|
||
if (summary.disabled) {
|
||
const meta = document.createElement('span');
|
||
meta.className = 'account-hoster-group-meta';
|
||
meta.textContent = `${summary.disabled} deaktiviert`;
|
||
header.appendChild(meta);
|
||
}
|
||
if (summary.error) {
|
||
const meta = document.createElement('span');
|
||
meta.className = 'account-hoster-group-meta error';
|
||
meta.textContent = `${summary.error} Fehler`;
|
||
header.appendChild(meta);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _accountListenersBound = false;
|
||
|
||
function renderAccounts() {
|
||
const container = document.getElementById('accountsList');
|
||
if (!container) return;
|
||
ensureAccountStatusEntries();
|
||
|
||
const allAccounts = getAllAccountsFlat();
|
||
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
|
||
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
|
||
|
||
if (allAccounts.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="accounts-empty">
|
||
<p>Keine Accounts vorhanden</p>
|
||
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
|
||
</div>`;
|
||
if (!_accountListenersBound) bindAccountListeners(container);
|
||
return;
|
||
}
|
||
|
||
const byHoster = {};
|
||
for (const { name, account } of allAccounts) {
|
||
if (!byHoster[name]) byHoster[name] = [];
|
||
byHoster[name].push(account);
|
||
}
|
||
|
||
let html = '';
|
||
for (const name of HOSTERS) {
|
||
const accounts = byHoster[name];
|
||
if (!accounts || accounts.length === 0) continue;
|
||
html += _buildAccountHosterGroupHtml(name, accounts);
|
||
}
|
||
container.innerHTML = html;
|
||
|
||
if (!_accountListenersBound) bindAccountListeners(container);
|
||
}
|
||
|
||
function _summarizeHosterGroup(accounts) {
|
||
let ok = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
|
||
for (const a of accounts) {
|
||
if (a.enabled === false) { disabled++; continue; }
|
||
const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked';
|
||
if (s === 'ok' || s === 'warn') ok++;
|
||
else if (s === 'error') error++;
|
||
else if (s === 'checking') checking++;
|
||
else unchecked++;
|
||
}
|
||
return { ok, error, checking, unchecked, disabled, total: accounts.length };
|
||
}
|
||
|
||
function _hosterGroupOpenState(name, summary) {
|
||
const prev = _hosterGroupOpenMemory.get(name);
|
||
if (prev && typeof prev === 'object') {
|
||
if (summary.error > (prev.errorsAtClose || 0)) {
|
||
_hosterGroupOpenMemory.delete(name);
|
||
return true;
|
||
}
|
||
return prev.state === 'open';
|
||
}
|
||
return summary.error > 0;
|
||
}
|
||
|
||
const _hosterGroupOpenMemory = new Map();
|
||
|
||
function _buildAccountHosterGroupHtml(name, accounts) {
|
||
const summary = _summarizeHosterGroup(accounts);
|
||
const isOpen = _hosterGroupOpenState(name, summary);
|
||
let dot = 'unchecked';
|
||
if (summary.error > 0) dot = 'error';
|
||
else if (summary.checking > 0) dot = 'checking';
|
||
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
|
||
const countLabel = `${summary.ok}/${summary.total}`;
|
||
const arrow = isOpen ? '▼' : '▶';
|
||
let cardsHtml = '';
|
||
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
|
||
const bodyStyle = isOpen ? '' : 'style="display:none"';
|
||
const lifeStat = _hosterLifetimeStat(name);
|
||
const lifeMeta = lifeStat && lifeStat.total > 0
|
||
? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>`
|
||
: '';
|
||
return `<div class="account-hoster-group" data-hoster-group="${name}">
|
||
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
|
||
<span class="panel-arrow">${arrow}</span>
|
||
<span class="account-status-dot status-${dot}"></span>
|
||
<span class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</span>
|
||
<span class="account-hoster-group-count">${countLabel}</span>
|
||
${summary.disabled ? `<span class="account-hoster-group-meta">${summary.disabled} deaktiviert</span>` : ''}
|
||
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
|
||
${lifeMeta}
|
||
</div>
|
||
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
|
||
</div>`;
|
||
}
|
||
|
||
let _hosterLifetimeCache = null;
|
||
function _hosterLifetimeStat(name) {
|
||
if (!_hosterLifetimeCache && window.Stats && Array.isArray(window._historyForStats)) {
|
||
_hosterLifetimeCache = window.Stats.summarizePerHoster(window._historyForStats, { lastNBatches: 50 });
|
||
}
|
||
return _hosterLifetimeCache ? _hosterLifetimeCache[name] : null;
|
||
}
|
||
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
|
||
|
||
// Single set of delegated listeners on the accounts container. Bound once on
|
||
// the first render and reused for every subsequent in-place update / card
|
||
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
|
||
// per render — with 20 accounts that's 180 listener create/destroy cycles on
|
||
// every enable/disable click.
|
||
function bindAccountListeners(container) {
|
||
_accountListenersBound = true;
|
||
container.addEventListener('click', (e) => {
|
||
const header = e.target.closest('[data-hoster-toggle]');
|
||
if (header && !e.target.closest('button')) {
|
||
const name = header.dataset.hosterToggle;
|
||
const group = header.closest('.account-hoster-group');
|
||
const body = group && group.querySelector('.account-hoster-group-body');
|
||
const arrow = header.querySelector('.panel-arrow');
|
||
if (body) {
|
||
const willOpen = body.style.display === 'none';
|
||
body.style.display = willOpen ? '' : 'none';
|
||
if (arrow) arrow.innerHTML = willOpen ? '▼' : '▶';
|
||
const summary = _summarizeHosterGroup(config.hosters[name] || []);
|
||
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
|
||
}
|
||
return;
|
||
}
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
|
||
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
|
||
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
|
||
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
|
||
if (btn.dataset.accountReactivate) {
|
||
const accountId = btn.dataset.accountReactivate;
|
||
const hoster = btn.dataset.accountReactivateHoster;
|
||
if (!hoster || !accountId) return;
|
||
e.stopPropagation();
|
||
window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => {
|
||
_sessionFailedKeys.delete(`${hoster}:${accountId}`);
|
||
renderAccounts();
|
||
showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv — nächste Batch verwendet ihn`);
|
||
}).catch(() => {});
|
||
return;
|
||
}
|
||
});
|
||
|
||
let draggedCard = null;
|
||
container.addEventListener('dragstart', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card) return;
|
||
draggedCard = card;
|
||
card.classList.add('dragging');
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
container.addEventListener('dragend', () => {
|
||
if (draggedCard) draggedCard.classList.remove('dragging');
|
||
draggedCard = null;
|
||
container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
|
||
});
|
||
container.addEventListener('dragover', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card || !draggedCard || draggedCard === card) return;
|
||
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
|
||
e.preventDefault();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||
const rect = card.getBoundingClientRect();
|
||
const midY = rect.top + rect.height / 2;
|
||
card.classList.toggle('drag-over-above', e.clientY < midY);
|
||
card.classList.toggle('drag-over-below', e.clientY >= midY);
|
||
});
|
||
container.addEventListener('dragleave', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (card) card.classList.remove('drag-over-above', 'drag-over-below');
|
||
});
|
||
container.addEventListener('drop', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card || !draggedCard || draggedCard === card) return;
|
||
e.preventDefault();
|
||
card.classList.remove('drag-over-above', 'drag-over-below');
|
||
const hosterName = card.dataset.accountHoster;
|
||
if (draggedCard.dataset.accountHoster !== hosterName) return;
|
||
|
||
const draggedId = draggedCard.dataset.accountId;
|
||
const targetId = card.dataset.accountId;
|
||
const accounts = config.hosters[hosterName];
|
||
if (!Array.isArray(accounts)) return;
|
||
|
||
const fromIdx = accounts.findIndex(a => a.id === draggedId);
|
||
if (fromIdx < 0) return;
|
||
const [moved] = accounts.splice(fromIdx, 1);
|
||
const rect = card.getBoundingClientRect();
|
||
const insertBefore = e.clientY < rect.top + rect.height / 2;
|
||
const newToIdx = accounts.findIndex(a => a.id === targetId);
|
||
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
|
||
|
||
// Move the DOM node in place — no full re-render.
|
||
if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
|
||
|
||
// The Primär / Fallback #N badges just changed for the whole group.
|
||
for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id);
|
||
|
||
// Persist in the background. saveConfig is idempotent; we don't need to
|
||
// await here or re-fetch — our in-memory config is already the truth.
|
||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||
});
|
||
}
|
||
|
||
async function toggleAccount(accountId) {
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
found.account.enabled = !found.account.enabled;
|
||
syncSelectedUploadHosters();
|
||
// In-place: swap only the one affected card. No full re-render, no IPC
|
||
// refetch, no flicker. Rapid click-toggles now feel instant even with 50
|
||
// accounts in the list.
|
||
updateAccountCard(accountId);
|
||
renderHosterSummary();
|
||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||
}
|
||
|
||
async function checkSingleAccount(accountId) {
|
||
if (!accountId || healthCheckRunning) return;
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
healthCheckRunning = true;
|
||
accountStatuses[accountId] = { status: 'checking', message: '' };
|
||
updateAccountCard(accountId);
|
||
try {
|
||
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
|
||
const rows = result && Array.isArray(result.results) ? result.results : [];
|
||
const row = rows.find(r => r.accountId === accountId);
|
||
if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' };
|
||
} catch (err) {
|
||
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
|
||
} finally {
|
||
healthCheckRunning = false;
|
||
}
|
||
updateAccountCard(accountId);
|
||
}
|
||
|
||
// Per-hoster overrides for the login form. VOE only accepts emails — the
|
||
// generic "Username / E-Mail" label sent users down a confusing rabbit hole
|
||
// (login fails → upload fetches login redirect → "CSRF token nicht gefunden").
|
||
// Other hosters that genuinely accept either keep the generic wording.
|
||
const LOGIN_FIELD_LABELS = {
|
||
'voe.sx': { label: 'E-Mail', placeholder: 'E-Mail-Adresse', inputType: 'email' }
|
||
};
|
||
|
||
function getCredsFieldsHtml(authType, account, hoster) {
|
||
account = account || {};
|
||
if (authType === 'login') {
|
||
const fld = (hoster && LOGIN_FIELD_LABELS[hoster]) || {
|
||
label: 'Username / E-Mail', placeholder: 'Username oder E-Mail', inputType: 'text'
|
||
};
|
||
return `
|
||
<div class="settings-row">
|
||
<label>${escapeHtml(fld.label)}</label>
|
||
<input type="${fld.inputType}" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Passwort</label>
|
||
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
|
||
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
||
</div>`;
|
||
}
|
||
// API key
|
||
return `
|
||
<div class="settings-row">
|
||
<label>API Key</label>
|
||
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
|
||
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
||
</div>`;
|
||
}
|
||
|
||
function openAccountModal(editAccountId) {
|
||
editingAccountId = editAccountId || null;
|
||
// Reset the two-step state — any previously validated snapshot from a prior
|
||
// modal session is stale and must not allow a no-recheck commit.
|
||
_resetAccountModalState();
|
||
const modal = document.getElementById('accountModal');
|
||
const title = document.getElementById('accountModalTitle');
|
||
const subtitle = document.getElementById('accountModalSubtitle');
|
||
const hosterRow = document.getElementById('accountHosterRow');
|
||
const hosterSelect = document.getElementById('accountHosterSelect');
|
||
const credsContainer = document.getElementById('accountCredsFields');
|
||
const statusEl = document.getElementById('accountModalStatus');
|
||
const saveBtn = document.getElementById('saveAccountBtn');
|
||
const labelInput = document.getElementById('accField_label');
|
||
|
||
statusEl.textContent = '';
|
||
statusEl.className = 'account-modal-status';
|
||
|
||
if (editingAccountId) {
|
||
// Edit mode
|
||
const found = findAccountById(editingAccountId);
|
||
if (!found) return;
|
||
title.textContent = 'Account bearbeiten';
|
||
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
|
||
hosterRow.style.display = 'none';
|
||
saveBtn.textContent = 'Prüfen';
|
||
if (labelInput) labelInput.value = found.account.label || '';
|
||
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
|
||
} else {
|
||
// Add mode — always show all options (multiple accounts per hoster allowed)
|
||
title.textContent = 'Account hinzufügen';
|
||
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
|
||
hosterRow.style.display = 'flex';
|
||
saveBtn.textContent = 'Prüfen';
|
||
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
|
||
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
|
||
).join('');
|
||
const firstOpt = HOSTER_ADD_OPTIONS[0];
|
||
if (labelInput) labelInput.value = '';
|
||
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
|
||
}
|
||
|
||
// Toggle visibility buttons
|
||
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const input = btn.previousElementSibling;
|
||
input.type = input.type === 'password' ? 'text' : 'password';
|
||
});
|
||
});
|
||
|
||
// Wire field invalidation: any change to a cred field after a green check
|
||
// drops the validated snapshot so the next click is a re-check, not a commit
|
||
// of unverified creds. Re-wired here every open because credsContainer's HTML
|
||
// was replaced.
|
||
_wireCredFieldInvalidation();
|
||
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeAccountModal() {
|
||
document.getElementById('accountModal').style.display = 'none';
|
||
_hideOtpField();
|
||
editingAccountId = null;
|
||
// Cancel any pending auto-close so a stale timer can't close a future modal
|
||
// the user reopens within the auto-close window.
|
||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||
_validatedCreds = null;
|
||
_accountModalBusy = false;
|
||
}
|
||
|
||
function openDeleteAccountModal(accountId) {
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
const modal = document.getElementById('deleteAccountModal');
|
||
const msg = document.getElementById('deleteAccountMessage');
|
||
msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`;
|
||
modal.dataset.accountId = accountId;
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeDeleteModal() {
|
||
document.getElementById('deleteAccountModal').style.display = 'none';
|
||
}
|
||
|
||
async function deleteAccount(accountId) {
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
// Remove account from the array
|
||
const accounts = config.hosters[found.name];
|
||
if (Array.isArray(accounts)) {
|
||
config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
|
||
}
|
||
delete accountStatuses[accountId];
|
||
// saveConfig is async — close the modal immediately so the UI feels
|
||
// responsive instead of waiting for the atomic write + safeStorage encrypt.
|
||
// The in-memory config already reflects the delete; the IPC just persists it.
|
||
closeDeleteModal();
|
||
ensureAccountStatusEntries();
|
||
syncSelectedUploadHosters();
|
||
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
|
||
renderAccounts();
|
||
renderHosterSummary();
|
||
// Fire-and-forget the persist. The earlier `await getConfig()` round-trip
|
||
// was redundant (we already have the truth in memory) and was the main
|
||
// source of perceived lag on add/delete.
|
||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||
}
|
||
|
||
function readAccountCredsFromModal(authType) {
|
||
const label = (document.getElementById('accField_label')?.value || '').trim();
|
||
if (authType === 'login') {
|
||
const username = (document.getElementById('accField_username')?.value || '').trim();
|
||
const password = (document.getElementById('accField_password')?.value || '').trim();
|
||
return { enabled: !!(username && password), authType: 'login', username, password, label };
|
||
}
|
||
// API
|
||
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
|
||
return { enabled: !!apiKey, authType: 'api', apiKey, label };
|
||
}
|
||
|
||
// --- Two-step account-modal state machine ---
|
||
//
|
||
// Goal: never persist invalid/unverified credentials to config.hosters. The
|
||
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
|
||
// the button label flips to "Anlegen" / "Speichern" → the next click commits
|
||
// to config. Editing any cred field between the two clicks drops the validated
|
||
// snapshot so the user can't sneak unverified creds through by editing
|
||
// post-green.
|
||
//
|
||
// Invariants enforced here:
|
||
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
|
||
// result for the currently-typed creds.
|
||
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
|
||
// before any await — guards against double-clicks producing duplicates.
|
||
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
|
||
// the new OTP, no config writes until green.
|
||
// 4. Edit mode hits the same path → bad edits never overwrite known-good
|
||
// creds on disk.
|
||
let _accountModalBusy = false;
|
||
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
|
||
let _autoCloseTimer = null;
|
||
// Session token used to ignore stale validate-credentials responses: if the
|
||
// user closes the modal mid-flight and reopens it, the late .then must NOT
|
||
// stomp the new session's state. Bumped on every modal reset.
|
||
let _accountModalSession = 0;
|
||
|
||
function _resetAccountModalState() {
|
||
_accountModalBusy = false;
|
||
_validatedCreds = null;
|
||
_accountModalSession++;
|
||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||
}
|
||
|
||
function _credsSnapshotKey(authType, creds) {
|
||
// Identity key for the typed creds — used to detect post-validation edits.
|
||
// Label changes do NOT invalidate (label is metadata, not a credential).
|
||
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||
return `api:${creds.apiKey || ''}`;
|
||
}
|
||
|
||
function _wireCredFieldInvalidation() {
|
||
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
|
||
// validated snapshot and reverts the button to "Prüfen". Label edits don't
|
||
// invalidate (label is metadata, not a credential). OTP edits don't either:
|
||
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
|
||
// these username+password+OTP, the resulting trust is on the creds; the user
|
||
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
|
||
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
|
||
for (const id of ids) {
|
||
const el = document.getElementById(id);
|
||
if (!el || el.dataset.invalidateBound === '1') continue;
|
||
el.addEventListener('input', () => {
|
||
if (_validatedCreds) {
|
||
_validatedCreds = null;
|
||
const saveBtn = document.getElementById('saveAccountBtn');
|
||
if (saveBtn) saveBtn.textContent = 'Prüfen';
|
||
const statusEl = document.getElementById('accountModalStatus');
|
||
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
|
||
}
|
||
});
|
||
el.dataset.invalidateBound = '1';
|
||
}
|
||
}
|
||
|
||
function _determineHosterContext() {
|
||
if (editingAccountId) {
|
||
const found = findAccountById(editingAccountId);
|
||
if (!found) return null;
|
||
return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true };
|
||
}
|
||
const selectValue = document.getElementById('accountHosterSelect')?.value;
|
||
if (!selectValue) return null;
|
||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
|
||
if (!opt) return null;
|
||
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
||
}
|
||
|
||
async function saveAccount() {
|
||
// SYNCHRONOUS re-entry guard — must come before any await. Without this a
|
||
// double-click before the first IPC returns triggers two saveAccount() calls
|
||
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
|
||
// synchronously and set synchronously, so the second click no-ops cleanly.
|
||
if (_accountModalBusy) return;
|
||
|
||
const ctx = _determineHosterContext();
|
||
if (!ctx) return;
|
||
const creds = readAccountCredsFromModal(ctx.authType);
|
||
const statusEl = document.getElementById('accountModalStatus');
|
||
const saveBtn = document.getElementById('saveAccountBtn');
|
||
if (!creds.enabled) {
|
||
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
|
||
statusEl.className = 'account-modal-status error';
|
||
return;
|
||
}
|
||
|
||
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
|
||
// EXACT same creds (label changes don't break this — label isn't part of the
|
||
// credential identity).
|
||
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
||
if (_validatedCreds &&
|
||
_validatedCreds.hosterName === ctx.hosterName &&
|
||
_validatedCreds.authType === ctx.authType &&
|
||
_validatedCreds.snapshot === snapshotKey) {
|
||
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
|
||
// leak _accountModalBusy=true and lock the user out for the session.
|
||
try {
|
||
_accountModalBusy = true;
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
|
||
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
|
||
} finally {
|
||
_accountModalBusy = false;
|
||
if (saveBtn) saveBtn.disabled = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
|
||
// Snapshot the session token so a stale late-arriving response from a
|
||
// closed-and-reopened modal can't stomp the new session's state.
|
||
const mySession = _accountModalSession;
|
||
_accountModalBusy = true;
|
||
saveBtn.disabled = true;
|
||
statusEl.textContent = 'Prüfe Login…';
|
||
statusEl.className = 'account-modal-status checking';
|
||
|
||
const otpInput = document.getElementById('accField_otp');
|
||
const otp = otpInput ? otpInput.value.trim() : '';
|
||
const payload = {
|
||
hoster: ctx.hosterName,
|
||
authType: ctx.authType,
|
||
username: creds.username || '',
|
||
password: creds.password || '',
|
||
apiKey: creds.apiKey || '',
|
||
otp
|
||
};
|
||
|
||
let row;
|
||
try {
|
||
row = await window.api.validateCredentials(payload);
|
||
} catch (err) {
|
||
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
|
||
} finally {
|
||
if (mySession === _accountModalSession) {
|
||
_accountModalBusy = false;
|
||
if (saveBtn) saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Stale response — modal was closed/reopened while we awaited. Drop it.
|
||
if (mySession !== _accountModalSession) return;
|
||
|
||
if (row && row.status === 'otp_required') {
|
||
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
|
||
statusEl.className = 'account-modal-status error';
|
||
_showOtpField();
|
||
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
|
||
saveBtn.textContent = 'Mit OTP prüfen';
|
||
return;
|
||
}
|
||
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
||
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
|
||
statusEl.className = 'account-modal-status ok';
|
||
_hideOtpField();
|
||
_validatedCreds = {
|
||
hosterName: ctx.hosterName,
|
||
authType: ctx.authType,
|
||
snapshot: snapshotKey,
|
||
status: row.status,
|
||
message: row.message || ''
|
||
};
|
||
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
|
||
return;
|
||
}
|
||
// error
|
||
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
||
statusEl.textContent = msg;
|
||
statusEl.className = 'account-modal-status error';
|
||
}
|
||
|
||
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
|
||
// Persist the validated creds to config.hosters and close the modal. By the
|
||
// time we reach this function the validate-credentials IPC has already
|
||
// returned ok/warn for these exact creds, so we skip a redundant re-check.
|
||
let accountId;
|
||
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
|
||
if (ctx.isEdit) {
|
||
accountId = ctx.accountId;
|
||
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
|
||
if (idx >= 0) {
|
||
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
|
||
}
|
||
} else {
|
||
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||
config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
|
||
}
|
||
await window.api.saveConfig({ hosters: config.hosters });
|
||
// Skip the redundant await getConfig() — the in-memory state is the source
|
||
// of truth for what we just wrote, decrypted creds didn't change, and the
|
||
// round-trip was the main lag source on add/delete.
|
||
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
|
||
ensureAccountStatusEntries();
|
||
syncSelectedUploadHosters();
|
||
// Targeted updates instead of the 4-panel cascade. For add we need a full
|
||
// accounts-list re-render (new card) and the hoster summary count; for edit
|
||
// we can update the single card. Settings panel only needs re-render if its
|
||
// hoster-summary section is visible — that's covered by renderHosterSummary.
|
||
if (ctx.isEdit) {
|
||
updateAccountCard(accountId);
|
||
} else {
|
||
renderAccounts();
|
||
}
|
||
renderHosterSummary();
|
||
// Auto-close after a short pause so the user sees the success state.
|
||
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
|
||
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
|
||
}
|
||
|
||
function _showOtpField() {
|
||
if (document.getElementById('accField_otp')) return; // already visible
|
||
const container = document.getElementById('accountCredsFields');
|
||
const otpHtml = `
|
||
<div class="settings-row" id="otpFieldRow">
|
||
<label>OTP Code</label>
|
||
<input type="text" class="key-input" id="accField_otp" placeholder="6-stelliger Code aus E-Mail" autocomplete="one-time-code" inputmode="numeric" maxlength="10">
|
||
</div>`;
|
||
container.insertAdjacentHTML('beforeend', otpHtml);
|
||
// Auto-focus the OTP field
|
||
setTimeout(() => document.getElementById('accField_otp')?.focus(), 50);
|
||
}
|
||
|
||
function _hideOtpField() {
|
||
const row = document.getElementById('otpFieldRow');
|
||
if (row) row.remove();
|
||
}
|
||
|
||
// --- History ---
|
||
async function loadHistory() {
|
||
const history = await window.api.getHistory();
|
||
window._historyForStats = history || [];
|
||
_invalidateHosterLifetimeCache();
|
||
const container = document.getElementById('historyContainer');
|
||
|
||
if (!history || history.length === 0) {
|
||
historyRowsData = [];
|
||
container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||
return;
|
||
}
|
||
|
||
historySortState = { key: 'date', direction: 'desc' };
|
||
historyRowsData = [];
|
||
let order = 0;
|
||
|
||
for (const batch of history) {
|
||
const dt = formatDateTime(batch.timestamp || new Date());
|
||
for (const file of (batch.files || [])) {
|
||
for (const result of (file.results || [])) {
|
||
if (result.status === 'aborted' || result.status === 'error') continue;
|
||
historyRowsData.push({
|
||
date: dt.text, dateTs: dt.ts,
|
||
filename: file.name || '', host: result.hoster || '',
|
||
link: result.download_url || result.embed_url || '',
|
||
isError: false, order: order++
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
renderHistoryTable(container);
|
||
}
|
||
|
||
async function exportHistory() {
|
||
const history = await window.api.getHistory();
|
||
if (!history || history.length === 0) {
|
||
alert('Kein Verlauf zum Exportieren vorhanden.');
|
||
return;
|
||
}
|
||
|
||
const asCsv = confirm('Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON');
|
||
const format = asCsv ? 'csv' : 'json';
|
||
const result = await window.api.exportHistory(format);
|
||
|
||
if (!result || result.canceled) return;
|
||
if (!result.ok) {
|
||
alert(result.error || 'Export fehlgeschlagen.');
|
||
return;
|
||
}
|
||
|
||
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
|
||
}
|
||
|
||
// Memoize sort result: invalidated only when data length changes or sort state changes.
|
||
// Selection changes and re-renders reuse the cached sorted array — a big win when
|
||
// the panel has thousands of rows and the sort is stable.
|
||
let _recentSortCache = { sig: '', result: [] };
|
||
|
||
function sortRecentFiles(data) {
|
||
const { key, direction } = recentSortState;
|
||
const sig = `${key}|${direction}|${data.length}`;
|
||
if (_recentSortCache.sig === sig) return _recentSortCache.result;
|
||
|
||
const sorted = data.slice();
|
||
const dir = direction === 'asc' ? 1 : -1;
|
||
sorted.sort((a, b) => {
|
||
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
|
||
if (key === 'filename') return dir * _collatorDE.compare(a.filename, b.filename);
|
||
if (key === 'host') return dir * _collatorDE.compare(a.host, b.host);
|
||
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
|
||
return 0;
|
||
});
|
||
_recentSortCache = { sig, result: sorted };
|
||
return sorted;
|
||
}
|
||
|
||
function updateRecentSortHeaders() {
|
||
const head = document.getElementById('recentFilesHead');
|
||
if (!head) return;
|
||
head.querySelectorAll('th[data-recent-sort]').forEach(th => {
|
||
const key = th.dataset.recentSort;
|
||
const active = recentSortState.key === key;
|
||
const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
||
th.classList.toggle('active', active);
|
||
const indicator = th.querySelector('.sort-indicator');
|
||
if (indicator) indicator.textContent = arrow;
|
||
});
|
||
}
|
||
|
||
let _recentListenersBound = false;
|
||
|
||
function _buildRecentRowHtml(row) {
|
||
const cls = `recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}`;
|
||
return `<tr class="${cls}" data-order="${row.order}" data-link="${escapeAttr(row.link)}">`
|
||
+ `<td>${escapeHtml(row.date)}</td>`
|
||
+ `<td title="${escapeAttr(row.filename)}">${escapeHtml(row.filename)}</td>`
|
||
+ `<td>${escapeHtml(row.host)}</td>`
|
||
+ `<td title="${escapeAttr(row.link)}">${escapeHtml(row.link)}</td>`
|
||
+ `</tr>`;
|
||
}
|
||
|
||
// Tracks the last rendered dataset so we can append-only when the user is just
|
||
// accumulating new uploads (the default case: sort=date desc, rows only grow).
|
||
let _recentLastRenderedSig = '';
|
||
let _recentLastRenderedLen = 0;
|
||
|
||
function renderRecentUploadsPanel() {
|
||
const tbody = document.getElementById('recentFilesBody');
|
||
if (!tbody) return;
|
||
if (!sessionFilesData.length) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
|
||
_recentLastRenderedSig = '';
|
||
_recentLastRenderedLen = 0;
|
||
return;
|
||
}
|
||
|
||
const rows = sortRecentFiles(sessionFilesData);
|
||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||
const dateDescAppendOnly = sig === 'date|desc'
|
||
&& _recentLastRenderedSig === sig
|
||
&& rows.length > _recentLastRenderedLen
|
||
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
|
||
|
||
let wasAppendOnly = false;
|
||
if (dateDescAppendOnly) {
|
||
// Fast path: only new rows (date desc puts newest on top) — insert them
|
||
// at the top without rebuilding the 5000-row tbody below.
|
||
const added = rows.length - _recentLastRenderedLen;
|
||
let html = '';
|
||
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
|
||
tbody.insertAdjacentHTML('afterbegin', html);
|
||
wasAppendOnly = true;
|
||
} else {
|
||
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
|
||
}
|
||
_recentLastRenderedSig = sig;
|
||
_recentLastRenderedLen = rows.length;
|
||
|
||
// Event delegation – bind once, not per-row
|
||
if (!_recentListenersBound) {
|
||
_recentListenersBound = true;
|
||
tbody.addEventListener('click', (e) => {
|
||
const tr = e.target.closest('.recent-file-row');
|
||
if (!tr) return;
|
||
// Clear queue selection when clicking in recent panel — class-toggle only.
|
||
if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); }
|
||
const id = parseInt(tr.dataset.order, 10);
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
|
||
else selectedRecentIds.add(id);
|
||
} else if (e.shiftKey && selectedRecentIds.size > 0) {
|
||
// Reuse the already-sorted array from the sort cache instead of
|
||
// querying every .recent-file-row in the DOM (O(visible) vs O(N)
|
||
// on large panels).
|
||
const sortedOrders = (_recentSortCache.result || sortRecentFiles(sessionFilesData))
|
||
.map(r => r.order);
|
||
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
|
||
const curIdx = sortedOrders.indexOf(id);
|
||
if (lastIdx >= 0 && curIdx >= 0) {
|
||
const from = Math.min(lastIdx, curIdx);
|
||
const to = Math.max(lastIdx, curIdx);
|
||
for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]);
|
||
}
|
||
} else {
|
||
selectedRecentIds.clear();
|
||
selectedRecentIds.add(id);
|
||
}
|
||
// Selection change — toggle classes, no tbody rebuild.
|
||
applyRecentSelectionClasses();
|
||
});
|
||
|
||
tbody.addEventListener('dblclick', (e) => {
|
||
const tr = e.target.closest('.recent-file-row');
|
||
if (!tr || tr.classList.contains('error')) return;
|
||
const link = tr.dataset.link;
|
||
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
||
});
|
||
}
|
||
|
||
// Sort headers only change when the sort state changes — skip on appends.
|
||
if (!wasAppendOnly) updateRecentSortHeaders();
|
||
}
|
||
|
||
function renderHistoryTable(container) {
|
||
if (!container || !historyRowsData.length) {
|
||
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||
return;
|
||
}
|
||
|
||
const rows = sortHistoryRows(historyRowsData);
|
||
const headerCell = (key, label) => {
|
||
const active = historySortState.key === key;
|
||
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
||
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
|
||
};
|
||
|
||
let html = `<table class="results-table history-table"><thead><tr>
|
||
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
|
||
</tr></thead><tbody>`;
|
||
|
||
const parts = [html];
|
||
const len = rows.length;
|
||
for (let i = 0; i < len; i++) {
|
||
const row = rows[i];
|
||
const link = row.link || '';
|
||
const date = escapeHtml(row.date);
|
||
const filename = escapeHtml(row.filename);
|
||
const host = escapeHtml(row.host);
|
||
const linkHtml = escapeHtml(link);
|
||
const linkAttr = escapeAttr(link);
|
||
parts.push('<tr class="history-row');
|
||
if (row.isError) parts.push(' error');
|
||
parts.push('" data-link="');
|
||
parts.push(linkAttr);
|
||
parts.push('"><td class="col-date">');
|
||
parts.push(date);
|
||
parts.push('</td><td class="col-filename">');
|
||
parts.push(filename);
|
||
parts.push('</td><td class="col-host">');
|
||
parts.push(host);
|
||
parts.push('</td><td class="col-link">');
|
||
parts.push(linkHtml);
|
||
parts.push('</td></tr>');
|
||
}
|
||
parts.push('</tbody></table>');
|
||
container.innerHTML = parts.join('');
|
||
|
||
// Delegated listeners: bind once per render-target instead of once per
|
||
// row/header. With a 5000-row history the per-row bind path was a
|
||
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
|
||
// dominant cause of "tab switching lags" in the user report.
|
||
if (!container.dataset.historyListenersBound) {
|
||
container.dataset.historyListenersBound = '1';
|
||
container.addEventListener('click', (e) => {
|
||
const th = e.target.closest('th.sortable');
|
||
if (th && container.contains(th)) {
|
||
const key = th.dataset.historySort;
|
||
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
|
||
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
|
||
renderHistoryTable(container);
|
||
return;
|
||
}
|
||
const row = e.target.closest('.history-row');
|
||
if (row && !row.classList.contains('error')) {
|
||
const link = row.dataset.link;
|
||
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function sortHistoryRows(rows) {
|
||
const { key, direction } = historySortState;
|
||
const factor = direction === 'asc' ? 1 : -1;
|
||
return rows.slice().sort((a, b) => {
|
||
const cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || ''));
|
||
return (cmp || a.order - b.order) * factor;
|
||
});
|
||
}
|
||
|
||
// Flush pending queue state on window close (sync IPC — blocks until save completes)
|
||
window.addEventListener('beforeunload', () => {
|
||
// Flush pending settings save if user changed settings right before closing
|
||
if (settingsSaveTimer) {
|
||
clearTimeout(settingsSaveTimer);
|
||
settingsSaveTimer = null;
|
||
try { saveSettings(); } catch {}
|
||
}
|
||
clearTimeout(queuePersistTimer);
|
||
queuePersistTimer = null;
|
||
// Drain pending done-removals synchronously before persisting so jobs the
|
||
// user expected to disappear (removeFromQueueOnDone=true) don't reappear
|
||
// on next launch. Microtask wouldn't run before the sync IPC below.
|
||
if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync();
|
||
const globalSettings = {
|
||
...(config.globalSettings || {}),
|
||
pendingQueue: buildPersistedQueueState()
|
||
};
|
||
config.globalSettings = globalSettings;
|
||
window.api.saveGlobalSettingsSync(globalSettings);
|
||
});
|
||
|
||
// --- Setup Listeners ---
|
||
function setupListeners() {
|
||
document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
|
||
document.getElementById('addFolderBtn').addEventListener('click', pickFolder);
|
||
document.getElementById('startUploadBtn').addEventListener('click', startUpload);
|
||
document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload);
|
||
|
||
// Recent files sort headers
|
||
document.getElementById('recentFilesHead').addEventListener('click', (e) => {
|
||
const th = e.target.closest('th[data-recent-sort]');
|
||
if (!th) return;
|
||
const key = th.dataset.recentSort;
|
||
if (recentSortState.key === key) {
|
||
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc';
|
||
} else {
|
||
recentSortState.key = key;
|
||
recentSortState.direction = key === 'date' ? 'desc' : 'asc';
|
||
}
|
||
renderRecentUploadsPanel();
|
||
});
|
||
|
||
// Recent files context menu
|
||
document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => {
|
||
const tr = e.target.closest('.recent-file-row');
|
||
if (!tr) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const id = parseInt(tr.dataset.order, 10);
|
||
if (!selectedRecentIds.has(id)) {
|
||
selectedRecentIds.clear();
|
||
selectedRecentIds.add(id);
|
||
renderRecentUploadsPanel();
|
||
}
|
||
const menu = document.getElementById('recentContextMenu');
|
||
menu.style.display = 'block';
|
||
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||
menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px';
|
||
});
|
||
|
||
document.getElementById('recentContextMenu').addEventListener('click', (e) => {
|
||
const item = e.target.closest('.ctx-item');
|
||
if (!item) return;
|
||
hideContextMenu();
|
||
const action = item.dataset.action;
|
||
if (action === 'recent-copy-links') copySelectedRecentLinks();
|
||
else if (action === 'recent-delete') deleteSelectedRecentFiles();
|
||
});
|
||
document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs);
|
||
document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs);
|
||
document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress);
|
||
document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads);
|
||
document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top'));
|
||
document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up'));
|
||
document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down'));
|
||
document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom'));
|
||
document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual'));
|
||
document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks);
|
||
document.getElementById('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles);
|
||
document.getElementById('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles);
|
||
document.getElementById('retryFailedBtn').addEventListener('click', () => {
|
||
queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); });
|
||
retrySelectedJobs();
|
||
});
|
||
document.getElementById('importLogBtn').addEventListener('click', importUploadLog);
|
||
document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection);
|
||
document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal);
|
||
document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal);
|
||
document.getElementById('selectAllHostersBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
||
input.checked = true;
|
||
input.closest('.hoster-option')?.classList.add('selected');
|
||
});
|
||
});
|
||
document.getElementById('clearHostersBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
||
input.checked = false;
|
||
input.closest('.hoster-option')?.classList.remove('selected');
|
||
});
|
||
});
|
||
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
||
|
||
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
||
if (!confirm('Verlauf wirklich löschen?')) return;
|
||
await window.api.clearHistory();
|
||
loadHistory();
|
||
});
|
||
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
|
||
|
||
// Auto health check toggle
|
||
const autoToggle = document.getElementById('autoHealthCheckToggle');
|
||
if (autoToggle) {
|
||
autoToggle.checked = autoHealthCheckEnabled;
|
||
autoToggle.addEventListener('change', (e) => {
|
||
autoHealthCheckEnabled = !!e.target.checked;
|
||
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
|
||
});
|
||
}
|
||
|
||
// Virtual scroll for large queues
|
||
const queueContainer = document.getElementById('queueContainer');
|
||
if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true });
|
||
|
||
// Queue table sorting
|
||
document.querySelectorAll('#queueTable th.sortable').forEach(th => {
|
||
th.addEventListener('click', (e) => {
|
||
// Don't sort if click was on the resizer handle
|
||
if (e.target.classList.contains('col-resizer')) return;
|
||
const key = th.dataset.sort;
|
||
if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc';
|
||
else { queueSortState.key = key; queueSortState.direction = 'asc'; }
|
||
_lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort
|
||
renderQueueTable();
|
||
});
|
||
});
|
||
|
||
// Queue table column resizing (JDownloader-style)
|
||
setupColumnResizing();
|
||
|
||
// Shutdown cancel
|
||
document.getElementById('cancelShutdownBtn').addEventListener('click', async () => {
|
||
await window.api.cancelShutdown();
|
||
if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; }
|
||
document.getElementById('shutdownOverlay').style.display = 'none';
|
||
});
|
||
|
||
// Click on empty area in queue → deselect all
|
||
document.getElementById('upload-view').addEventListener('click', (e) => {
|
||
if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) {
|
||
if (selectedJobIds.size > 0) {
|
||
selectedJobIds.clear();
|
||
renderQueueTable();
|
||
updateQueueActionButtons();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Right-click on upload view background
|
||
document.getElementById('upload-view').addEventListener('contextmenu', (e) => {
|
||
if (e.target.closest('.queue-row')) return; // handled per row
|
||
if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue
|
||
e.preventDefault();
|
||
showContextMenu(e.clientX, e.clientY);
|
||
});
|
||
|
||
document.getElementById('hosterModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'hosterModal') cancelHosterModal();
|
||
});
|
||
|
||
// Account management
|
||
document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null));
|
||
document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal);
|
||
document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal);
|
||
document.getElementById('saveAccountBtn').addEventListener('click', saveAccount);
|
||
document.getElementById('accountModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'accountModal') closeAccountModal();
|
||
});
|
||
|
||
// Account hoster select change → update credential fields
|
||
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
|
||
const authType = opt ? opt.authType : 'login';
|
||
const credsContainer = document.getElementById('accountCredsFields');
|
||
credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value);
|
||
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const input = btn.previousElementSibling;
|
||
input.type = input.type === 'password' ? 'text' : 'password';
|
||
});
|
||
});
|
||
document.getElementById('accountModalStatus').textContent = '';
|
||
document.getElementById('accountModalStatus').className = 'account-modal-status';
|
||
// Hoster changed → any prior validation is stale by construction. Drop the
|
||
// snapshot and revert the button so the user has to re-Prüfen.
|
||
_validatedCreds = null;
|
||
const sb = document.getElementById('saveAccountBtn');
|
||
if (sb) sb.textContent = 'Prüfen';
|
||
// The cred inputs were just replaced — rewire invalidation listeners on
|
||
// the fresh elements so post-validation edits still revert the button.
|
||
_wireCredFieldInvalidation();
|
||
});
|
||
|
||
// Delete account modal
|
||
document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal);
|
||
document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal);
|
||
document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
|
||
const modal = document.getElementById('deleteAccountModal');
|
||
const accountId = modal.dataset.accountId;
|
||
if (accountId) deleteAccount(accountId);
|
||
});
|
||
document.getElementById('deleteAccountModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'deleteAccountModal') closeDeleteModal();
|
||
});
|
||
|
||
// Job log modal
|
||
document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal);
|
||
document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal);
|
||
document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard);
|
||
document.getElementById('jobLogModal')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'jobLogModal') hideJobLogModal();
|
||
});
|
||
}
|
||
|
||
// --- Update UI ---
|
||
function showUpdateBanner(info) {
|
||
const banner = document.getElementById('updateBanner');
|
||
const msg = document.getElementById('updateMessage');
|
||
if (!banner || !msg) return;
|
||
msg.textContent = `Update v${info.remoteVersion} verfügbar`;
|
||
banner.style.display = 'flex';
|
||
document.getElementById('installUpdateBtn').onclick = async () => {
|
||
msg.textContent = 'Update wird heruntergeladen...';
|
||
document.getElementById('installUpdateBtn').disabled = true;
|
||
await persistQueueStateNow().catch(() => {}); // Save queue before update restart
|
||
await window.api.installUpdate();
|
||
};
|
||
document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; };
|
||
}
|
||
|
||
function handleUpdateProgress(data) {
|
||
const msg = document.getElementById('updateMessage');
|
||
if (!msg) return;
|
||
if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`;
|
||
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
|
||
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
|
||
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';
|
||
else if (data.stage === 'error') {
|
||
msg.textContent = `Update fehlgeschlagen: ${data.error}`;
|
||
const btn = document.getElementById('installUpdateBtn');
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
|
||
}
|
||
}
|
||
|
||
// --- Shutdown ---
|
||
let shutdownCountdownInterval = null;
|
||
function handleShutdownCountdown(data) {
|
||
const overlay = document.getElementById('shutdownOverlay');
|
||
const msgEl = document.getElementById('shutdownMessage');
|
||
const secEl = document.getElementById('shutdownSeconds');
|
||
overlay.style.display = 'flex';
|
||
|
||
const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' };
|
||
let remaining = data.seconds || 60;
|
||
secEl.textContent = remaining;
|
||
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
|
||
|
||
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
|
||
shutdownCountdownInterval = setInterval(() => {
|
||
remaining--;
|
||
secEl.textContent = remaining;
|
||
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
|
||
if (remaining <= 0) { clearInterval(shutdownCountdownInterval); }
|
||
}, 1000);
|
||
}
|
||
|
||
// --- Auto-deduplicate restored queue against own upload log on startup ---
|
||
async function _autoDeduplicateFromLog() {
|
||
if (queueJobs.length === 0) return;
|
||
try {
|
||
const entries = await window.api.readOwnUploadLog();
|
||
if (!entries || entries.length === 0) return;
|
||
// Only 'done' jobs are dropped here (declutter completed uploads). Pending
|
||
// and failed jobs survive even if their name+hoster is in the log — they're
|
||
// intentional queued work. Decision lives in lib/queue-dedup.js (Node-tested,
|
||
// see tests/queue-dedup.test.js) so it can't silently regress to nuking the
|
||
// whole restored queue on restart/update.
|
||
const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries);
|
||
if (removed.length > 0) {
|
||
queueJobs = kept;
|
||
for (const job of removed) {
|
||
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
|
||
}
|
||
rebuildJobIndex();
|
||
syncSelectedFilesFromQueue();
|
||
window.api.debugLog(`auto-dedup: removed ${removed.length} already-uploaded (done) jobs from restored queue (${entries.length} log entries)`);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// --- Log import: remove already-uploaded file+hoster combos from queue ---
|
||
async function importUploadLog() {
|
||
const result = await window.api.importUploadLog();
|
||
if (!result || result.canceled) return;
|
||
const entries = result.entries || [];
|
||
if (entries.length === 0) {
|
||
showCopyToast('Keine Einträge im Log gefunden');
|
||
return;
|
||
}
|
||
|
||
// Build lookup Set: "filename_lower|hoster"
|
||
const logKeys = new Set();
|
||
for (const entry of entries) {
|
||
logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`);
|
||
}
|
||
|
||
// Find queue jobs that match (already uploaded)
|
||
let removed = 0;
|
||
queueJobs = queueJobs.filter(job => {
|
||
const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`;
|
||
if (logKeys.has(key) && job.status !== 'done') {
|
||
removeJobFromIndex(job);
|
||
// Mark as completed so buildQueuePreview won't re-create them
|
||
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
|
||
removed++;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (removed > 0) {
|
||
selectedJobIds.clear();
|
||
syncSelectedFilesFromQueue();
|
||
rebuildJobIndex();
|
||
renderQueueTable();
|
||
updateUploadView();
|
||
updateStatusBar();
|
||
persistQueueStateSoon(true);
|
||
}
|
||
|
||
showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`);
|
||
}
|
||
|
||
// --- Link operations ---
|
||
function copyAllLinks() {
|
||
const rows = queueJobs
|
||
.filter(j => j.status === 'done' && j.result)
|
||
.map(j => ({
|
||
fileName: j.fileName || '',
|
||
hoster: j.hoster || '',
|
||
url: j.result.download_url || j.result.embed_url || ''
|
||
}))
|
||
.filter(r => r.url);
|
||
if (rows.length === 0) return;
|
||
const formatEl = document.getElementById('linkExportFormat');
|
||
const fmt = (formatEl && formatEl.value) || 'plain';
|
||
const text = window.Stats ? window.Stats.formatLinks(rows, fmt) : rows.map(r => r.url).join('\n');
|
||
window.api.copyToClipboard(text);
|
||
showCopyToast(`${rows.length} Link${rows.length === 1 ? '' : 's'} als ${fmt.toUpperCase()} kopiert`);
|
||
}
|
||
|
||
// --- Utilities ---
|
||
function formatSize(bytes) {
|
||
if (!bytes || bytes <= 0) return '0 B';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
|
||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function formatSpeed(kbs) {
|
||
if (!kbs || kbs <= 0) return '0 kB/s';
|
||
if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s';
|
||
return Math.round(kbs) + ' kB/s';
|
||
}
|
||
|
||
function formatTime(seconds) {
|
||
if (!seconds || seconds <= 0) return '00:00';
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = seconds % 60;
|
||
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||
return `${pad(m)}:${pad(s)}`;
|
||
}
|
||
|
||
function pad(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
||
|
||
function formatDateTime(value) {
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date;
|
||
return {
|
||
ts: safeDate.getTime(),
|
||
text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
+ ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||
};
|
||
}
|
||
|
||
function loadAutoCheckPreference() {
|
||
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
|
||
catch { return true; }
|
||
}
|
||
|
||
// --- Queue table column resizing (JDownloader-style) ---
|
||
// Two-tier widths: _idealColumnWidths is what the user set (persisted); the
|
||
// displayed widths are scaled proportionally if the window is too narrow to fit
|
||
// all ideals (fullscreen → windowed). We never overwrite ideals just because
|
||
// the window shrunk — only an explicit drag updates the ideal for that column.
|
||
const _idealColumnWidths = {};
|
||
|
||
function restoreQueueColumnWidths() {
|
||
try {
|
||
const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY);
|
||
if (raw) {
|
||
const widths = JSON.parse(raw);
|
||
if (widths && typeof widths === 'object') {
|
||
for (const [col, px] of Object.entries(widths)) {
|
||
if (typeof px === 'number' && px > 20) _idealColumnWidths[col] = px;
|
||
}
|
||
}
|
||
}
|
||
_applyFittedColumnWidths();
|
||
} catch {}
|
||
}
|
||
|
||
function saveDraggedColumnWidth(col, width) {
|
||
// Called from the resizer onUp: the dragged column's new width becomes its
|
||
// new ideal. Other columns keep their saved ideals untouched (so a drag
|
||
// while the window is small doesn't bake the scaled values in).
|
||
if (!col || typeof width !== 'number' || width < 40) return;
|
||
_idealColumnWidths[col] = width;
|
||
try { localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(_idealColumnWidths)); } catch {}
|
||
_applyFittedColumnWidths();
|
||
}
|
||
|
||
function _applyFittedColumnWidths() {
|
||
const container = document.getElementById('queueContainer');
|
||
if (!container) return;
|
||
const ths = document.querySelectorAll('#queueTable th[data-col]');
|
||
if (!ths.length) return;
|
||
|
||
const entries = [];
|
||
let total = 0;
|
||
ths.forEach(th => {
|
||
// Fall back to the column's currently-measured width if no ideal exists
|
||
// yet (first render before the user ever dragged).
|
||
const ideal = _idealColumnWidths[th.dataset.col] || th.getBoundingClientRect().width || 0;
|
||
entries.push({ th, ideal });
|
||
total += ideal;
|
||
});
|
||
if (total <= 0) return;
|
||
|
||
const available = container.clientWidth;
|
||
if (available <= 0) return;
|
||
const MIN = 40;
|
||
|
||
if (total <= available) {
|
||
entries.forEach(({ th, ideal }) => { th.style.width = ideal + 'px'; });
|
||
return;
|
||
}
|
||
// Scale all columns proportionally so they exactly fit the available width.
|
||
const scale = available / total;
|
||
entries.forEach(({ th, ideal }) => {
|
||
th.style.width = Math.max(MIN, Math.round(ideal * scale)) + 'px';
|
||
});
|
||
}
|
||
|
||
// Debounced window-resize refit. Fires on every window size change — fullscreen
|
||
// → windowed, dragging the window edge, monitor unplug — and reshapes columns
|
||
// to the new viewport so the user never has to drag the window wider just to
|
||
// see a hidden column.
|
||
let _columnRefitTimer = null;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(_columnRefitTimer);
|
||
_columnRefitTimer = setTimeout(_applyFittedColumnWidths, 60);
|
||
});
|
||
|
||
function setupColumnResizing() {
|
||
const headers = document.querySelectorAll('#queueTable th[data-col]');
|
||
headers.forEach(th => {
|
||
const resizer = th.querySelector('.col-resizer');
|
||
if (!resizer) return;
|
||
|
||
resizer.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const startX = e.clientX;
|
||
const startWidth = th.getBoundingClientRect().width;
|
||
resizer.classList.add('dragging');
|
||
document.body.classList.add('col-resizing');
|
||
|
||
const onMove = (ev) => {
|
||
const delta = ev.clientX - startX;
|
||
const newWidth = Math.max(40, startWidth + delta);
|
||
th.style.width = newWidth + 'px';
|
||
};
|
||
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
resizer.classList.remove('dragging');
|
||
document.body.classList.remove('col-resizing');
|
||
// Only the dragged column's new width becomes its new ideal; other
|
||
// columns keep their saved ideals (so dragging while the window is
|
||
// narrow doesn't permanently shrink everything else).
|
||
saveDraggedColumnWidth(th.dataset.col, th.getBoundingClientRect().width);
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Single-pass escape instead of 4 chained .replace(/x/g, ...) calls.
|
||
// Hot path on large table rebuilds — every text cell runs through one of these.
|
||
const _HTML_ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
||
const _HTML_ESC_RE = /[&<>"]/g;
|
||
const _ATTR_ESC_MAP = { '&': '&', '"': '"', "'": ''' };
|
||
const _ATTR_ESC_RE = /[&"']/g;
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]);
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]);
|
||
}
|
||
|
||
function showCopyToast(msg, durationMs) {
|
||
const toast = document.getElementById('copyToast');
|
||
toast.textContent = msg;
|
||
toast.classList.add('show');
|
||
clearTimeout(toast._timer);
|
||
toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 1500);
|
||
}
|
||
|
||
// --- Resize handle for recent-files panel ---
|
||
{
|
||
const resizer = document.getElementById('recentFilesResizer');
|
||
const panel = document.getElementById('recentFilesPanel');
|
||
if (resizer && panel) {
|
||
let startY = 0;
|
||
let startH = 0;
|
||
|
||
resizer.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
startY = e.clientY;
|
||
startH = panel.getBoundingClientRect().height;
|
||
resizer.classList.add('dragging');
|
||
document.body.style.cursor = 'ns-resize';
|
||
document.body.style.userSelect = 'none';
|
||
|
||
const onMove = (e2) => {
|
||
const delta = startY - e2.clientY;
|
||
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta));
|
||
panel.style.flex = `0 0 ${newH}px`;
|
||
};
|
||
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
resizer.classList.remove('dragging');
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- Recent panel tabs ---
|
||
document.querySelectorAll('.recent-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
const panel = document.getElementById(tab.dataset.panel);
|
||
if (panel) panel.classList.add('active');
|
||
const hint = document.getElementById('recentFilesHint');
|
||
if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links';
|
||
});
|
||
});
|
||
|
||
// --- Stats panel update ---
|
||
let statsStartTime = 0;
|
||
let statsRunTimer = null;
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes <= 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i];
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = seconds % 60;
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function updateStatsPanel() {
|
||
const stats = _computeQueueStats();
|
||
const remaining = stats.total - stats.done - stats.errors;
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total;
|
||
if (el('statQueueDone')) el('statQueueDone').textContent = stats.done;
|
||
if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining;
|
||
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress;
|
||
if (el('statQueueError')) el('statQueueError').textContent = stats.errors;
|
||
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize);
|
||
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize);
|
||
|
||
const speed = lastUploadStats.globalSpeedKbs || 0;
|
||
if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s';
|
||
if (el('statEta')) {
|
||
if (speed > 0 && stats.remainingSize > 0) {
|
||
el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024)));
|
||
} else {
|
||
el('statEta').textContent = '--:--';
|
||
}
|
||
}
|
||
if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0);
|
||
}
|
||
|
||
// --- Start ---
|
||
init();
|