diff --git a/README.md b/README.md index e144ad0..e9aa0b7 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr - Manage multiple accounts per hoster with validation, health checks, automatic rotation, and inline OTP completion. - Filter uploads, accounts, and history from task-focused sidebars without changing the underlying queue. - Add files by drag and drop or file selection and monitor live queue progress. +- Use the complete interface in English or German and switch languages without restarting the app. - Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup. -- Keep local upload history and copy completed links in bulk. +- Keep local upload history, choose a retention period, and copy completed links in bulk. - Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client. - Check for updates from Settings or Help; when a newer release is available, install it from the header update action and accessible update dialog. diff --git a/assets/product-overview.png b/assets/product-overview.png index 514cdda..799d2e3 100644 Binary files a/assets/product-overview.png and b/assets/product-overview.png differ diff --git a/lib/config-store.js b/lib/config-store.js index 99df744..5881d91 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -56,6 +56,7 @@ const DEFAULTS = { 'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS } }, globalSettings: { + language: 'en', alwaysOnTop: false, shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', @@ -73,6 +74,7 @@ const DEFAULTS = { resumeQueueOnLaunch: true, parallelUploadCount: 0, // 0 = use per-hoster limits only scaleParallelUploads: false, + lastBrowseDirectory: '', removeFromQueueOnDone: false, showDropTarget: false, globalMaxSpeedKbs: 0, // 0 = unlimited global speed @@ -575,6 +577,19 @@ class ConfigStore { }, options); } + saveLastBrowseDirectory(directory) { + const snapshot = String(directory || '').trim(); + return this._enqueueWrite(() => { + const current = this.load(); + current.globalSettings = { + ...(current.globalSettings || {}), + lastBrowseDirectory: snapshot + }; + this._guardHosters(current, false); + return this._commit(current); + }); + } + saveRendererGlobalSettings(globalSettings) { const snapshot = this._clone(globalSettings || {}); return this._enqueueWrite(() => { @@ -585,6 +600,7 @@ class ConfigStore { current.globalSettings = { ...snapshot, pendingQueue: currentGlobalSettings.pendingQueue ?? null, + lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '', diagnostics: this._clone(currentGlobalSettings.diagnostics || {}), historyRetention: currentGlobalSettings.historyRetention || 'all', remote: { diff --git a/lib/updater.js b/lib/updater.js index 62745c9..13803b5 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -6,6 +6,7 @@ const { app } = require('electron'); const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload'; const GITEA_BASE = 'https://git.24-music.de'; const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`; +const GITHUB_RELEASE_URL = 'https://api.github.com/repos/Sucukdeluxe/Multi-Hoster-Upload/releases/tags'; const CHECK_TIMEOUT = 15000; @@ -86,6 +87,33 @@ async function fetchJson(url, signal) { } } +async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl = fetch) { + const version = String(remoteVersion || '').replace(/^v/i, '').trim(); + if (!/^\d+\.\d+\.\d+$/.test(version)) return String(fallback || ''); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT); + try { + const response = await fetchImpl(`${GITHUB_RELEASE_URL}/v${version}`, { + method: 'GET', + redirect: 'follow', + signal: controller.signal, + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Multi-Hoster-Upload' + } + }); + if (!response.ok) return String(fallback || ''); + const release = await response.json(); + const notes = typeof release?.body === 'string' ? release.body.trim() : ''; + return notes || String(fallback || ''); + } catch { + return String(fallback || ''); + } finally { + clearTimeout(timeout); + } +} + async function checkForUpdate() { // Return cached result if fresh if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) { @@ -116,6 +144,7 @@ async function checkForUpdate() { return { available: false, reason: 'Kein Setup-Asset im Release gefunden' }; } + const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || ''); cachedCheck = { available: true, currentVersion, @@ -126,7 +155,7 @@ async function checkForUpdate() { assetSize: setupAsset.size, assetName: setupAsset.name, latestYmlUrl: latestYml ? latestYml.browser_download_url : null, - releaseNotes: release.body || '' + releaseNotes }; cachedCheckTs = Date.now(); return cachedCheck; @@ -293,4 +322,4 @@ function abortUpdate() { } } -module.exports = { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion }; +module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion }; diff --git a/main.js b/main.js index 118bcb2..ad88be0 100644 --- a/main.js +++ b/main.js @@ -1364,6 +1364,7 @@ function createWindow() { minWidth: 800, minHeight: 550, backgroundColor: '#0f0f0f', + icon: path.join(__dirname, 'assets', 'app_icon.ico'), autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -1799,15 +1800,34 @@ async function _dispatchHealthCheck(hoster, hosterConfig, otp) { return { status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } +function getUploadBrowseDirectory() { + const savedDirectory = configStore.load().globalSettings.lastBrowseDirectory; + if (savedDirectory) { + try { + if (fs.statSync(savedDirectory).isDirectory()) return savedDirectory; + } catch {} + } + return app.getPath('downloads'); +} + +async function rememberUploadBrowseDirectory(selectedPath, selectedDirectory = false) { + if (!selectedPath) return; + const directory = selectedDirectory ? selectedPath : path.dirname(selectedPath); + await configStore.saveLastBrowseDirectory(directory); +} + ipcMain.handle('select-files', async () => { const result = await dialog.showOpenDialog(mainWindow, { + defaultPath: getUploadBrowseDirectory(), properties: ['openFile', 'multiSelections'], filters: [ { name: 'Alle Dateien', extensions: ['*'] }, { name: 'Videos', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] } ] }); - return result.canceled ? null : result.filePaths; + if (result.canceled || !result.filePaths.length) return null; + await rememberUploadBrowseDirectory(result.filePaths[0]); + return result.filePaths; }); // Debug self-test: runs a minimal upload in the main process to verify events work @@ -1837,9 +1857,11 @@ ipcMain.handle('debug-test-upload', async () => { ipcMain.handle('select-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { + defaultPath: getUploadBrowseDirectory(), properties: ['openDirectory', 'multiSelections'] }); if (result.canceled || !result.filePaths.length) return null; + await rememberUploadBrowseDirectory(result.filePaths[0], true); const files = []; for (const folder of result.filePaths) await walkFolderAsync(folder, files); @@ -1848,9 +1870,11 @@ ipcMain.handle('select-folder', async () => { ipcMain.handle('select-folder-with-sizes', async () => { const result = await dialog.showOpenDialog(mainWindow, { + defaultPath: getUploadBrowseDirectory(), properties: ['openDirectory', 'multiSelections'] }); if (result.canceled || !result.filePaths.length) return null; + await rememberUploadBrowseDirectory(result.filePaths[0], true); const files = []; for (const folder of result.filePaths) await walkFolderAsync(folder, files); diff --git a/package-lock.json b/package-lock.json index e06ebe2..ae64d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 3c4192f..99b24e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.0", + "version": "2.1.1", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index e073dbf..89ea617 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1,4 +1,14 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; +const uiLocalizer = window.I18n.createDomLocalizer(document); +uiLocalizer.start('en'); + +function setUiLanguage(value) { + return uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value)); +} + +function getUiLocale() { + return uiLocalizer.getLanguage() === 'de' ? 'de-DE' : 'en-US'; +} // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ @@ -260,6 +270,9 @@ function flushConfigWrites() { let _restoredSnapshotSavedAt = null; let settingsSaveTimer = null; const settingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performSaveSettings); +let settingsBaseline = ''; +let settingsDirty = false; +let settingsSaving = false; let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx'; @@ -342,6 +355,7 @@ window.addEventListener('unhandledrejection', (e) => { // --- Init --- async function init() { config = await window.api.getConfig(); + setUiLanguage(config.globalSettings?.language); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); ensureAccountStatusEntries(); @@ -527,6 +541,8 @@ function _isHistoryTabActive() { if (nextView) nextView.classList.add('active'); activeTab = tab; syncTabIndicator(tab); + const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active'); + _syncSidebarIndicator(activeSidebarButton, true); if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) { loadHistory(); } @@ -3126,7 +3142,7 @@ async function showJobLogModal() { } const fmt = (e) => { - const t = new Date(e.ts || Date.now()).toLocaleTimeString('de-DE', { hour12: false }) + '.' + + const t = new Date(e.ts || Date.now()).toLocaleTimeString(getUiLocale(), { hour12: false }) + '.' + String((e.ts || 0) % 1000).padStart(3, '0'); if (e.kind === 'progress') { const attempt = e.attempt ? ` (${e.attempt}/${e.maxAttempts || '?'})` : ''; @@ -3420,15 +3436,41 @@ function _computeQueueStats() { function _setSidebarCount(id, value) { const element = document.getElementById(id); - if (element) element.textContent = Number(value || 0).toLocaleString('de-DE'); + if (element) element.textContent = Number(value || 0).toLocaleString(getUiLocale()); +} + +function _syncSidebarIndicator(button, immediate = false) { + const navigation = button?.closest('.view-sidebar-navigation, .settings-navigation'); + const indicator = navigation?.querySelector(':scope > .view-sidebar-indicator, :scope > .settings-nav-indicator'); + if (!indicator || !button) return; + const navigationRect = navigation.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); + if (buttonRect.width === 0 || buttonRect.height === 0) return; + const firstPosition = indicator.dataset.ready !== 'true'; + if (immediate || firstPosition) indicator.style.transition = 'none'; + indicator.style.width = `${buttonRect.width}px`; + indicator.style.height = `${buttonRect.height}px`; + indicator.style.transform = `translate(${buttonRect.left - navigationRect.left}px, ${buttonRect.top - navigationRect.top}px)`; + if (immediate || firstPosition) { + indicator.getBoundingClientRect(); + requestAnimationFrame(() => { + indicator.style.transition = ''; + indicator.dataset.ready = 'true'; + }); + } } function _syncSidebarFilterButtons(selector, datasetKey, value) { + let activeButton = null; + let selectionChanged = false; document.querySelectorAll(selector).forEach(button => { const active = button.dataset[datasetKey] === value; + if (active && !button.classList.contains('active')) selectionChanged = true; button.classList.toggle('active', active); button.setAttribute('aria-pressed', active ? 'true' : 'false'); + if (active) activeButton = button; }); + _syncSidebarIndicator(activeButton, !selectionChanged); } function setUploadSidebarFilter(value) { @@ -3490,7 +3532,7 @@ function updateAccountSidebarSummary(allAccounts = getAllAccountsFlat()) { label.textContent = getHosterLabel(name); const count = document.createElement('span'); count.className = 'view-sidebar-badge'; - count.textContent = hosterCounts.get(name).toLocaleString('de-DE'); + count.textContent = hosterCounts.get(name).toLocaleString(getUiLocale()); row.append(dot, label, count); return row; })); @@ -3543,7 +3585,15 @@ function updateHistorySidebarSummary() { _setSidebarCount('historySidebarErrorCount', historySidebarCounts.error); const retention = document.getElementById('historySidebarRetention'); const select = document.getElementById('historyRetentionSelect'); - if (retention && select) retention.textContent = select.selectedOptions[0]?.textContent || 'Alles behalten'; + const labels = { + all: 'Alles behalten', + '7d': 'Letzte 7 Tage', + '30d': 'Letzte 30 Tage', + '90d': 'Letzte 90 Tage', + '1000': 'Letzte 1000 Uploads', + '100': 'Letzte 100 Uploads' + }; + if (retention && select) retention.textContent = labels[select.value] || labels.all; } function updateStatusBar() { @@ -3733,6 +3783,7 @@ function renderSettings() {
Keine passende Einstellung gefunden.
@@ -3758,6 +3809,24 @@ function renderSettings() { pages.allgemein.innerHTML = ` ${pageHeader('Allgemein', 'Fensterverhalten, Drop-Target und Programmupdates.')}Noch keine Uploads.
'; return; } @@ -5534,6 +5793,7 @@ async function loadHistory() { } updateHistorySidebarSummary(); + syncHistoryClearAction(); renderHistoryTable(container); } @@ -5784,7 +6044,7 @@ function renderHistoryTable(container) { if (notice) { if (total > HISTORY_RENDER_CAP) { notice.style.display = ''; - notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`; + notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString(getUiLocale())} von ${total.toLocaleString(getUiLocale())} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`; } else { notice.style.display = 'none'; } @@ -5970,6 +6230,15 @@ function setupListeners() { _syncSidebarFilterButtons('[data-upload-sidebar-target]', 'uploadSidebarTarget', uploadSidebarFilter); _syncSidebarFilterButtons('[data-accounts-sidebar-filter]', 'accountsSidebarFilter', accountSidebarFilter); _syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', historySidebarFilter); + let sidebarResizeFrame = 0; + window.addEventListener('resize', () => { + window.cancelAnimationFrame(sidebarResizeFrame); + sidebarResizeFrame = window.requestAnimationFrame(() => { + document.querySelectorAll('.view.active .view-sidebar-navigation > .view-sidebar-item.active, .view.active .settings-navigation > .settings-nav-button.active').forEach(button => { + _syncSidebarIndicator(button, true); + }); + }); + }); document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFolderBtn').addEventListener('click', pickFolder); document.getElementById('startUploadBtn').addEventListener('click', startUpload); @@ -6064,16 +6333,73 @@ function setupListeners() { } }, true); - document.getElementById('clearHistoryBtn').addEventListener('click', async () => { - if (!confirm('Verlauf wirklich löschen?')) return; - try { - await runConfigWrite(() => window.api.clearHistory()); - loadHistory(); - } catch (error) { - showCopyToast(error.message || String(error)); + document.getElementById('clearHistoryBtn').addEventListener('click', openHistoryClearModal); + document.getElementById('confirmHistoryClearBtn').addEventListener('click', confirmHistoryClear); + document.getElementById('cancelHistoryClearBtn').addEventListener('click', closeHistoryClearModal); + document.getElementById('closeHistoryClearModalBtn').addEventListener('click', closeHistoryClearModal); + document.getElementById('historyClearModal').addEventListener('click', event => { + if (event.target.id === 'historyClearModal') closeHistoryClearModal(); + }); + document.addEventListener('keydown', event => { + const modal = document.getElementById('historyClearModal'); + if (modal?.style.display !== 'flex' || event.key !== 'Escape') return; + event.preventDefault(); + closeHistoryClearModal(); + }, true); + document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); + + const historyRetentionPicker = document.getElementById('historyRetentionPicker'); + const historyRetentionTrigger = document.getElementById('historyRetentionTrigger'); + const historyRetentionMenu = document.getElementById('historyRetentionMenu'); + historyRetentionTrigger.addEventListener('click', event => { + event.stopPropagation(); + if (window.getComputedStyle(historyRetentionMenu).display === 'none' || historyRetentionMenu.classList.contains('menu-closing')) { + openHistoryRetentionMenu(); + } else { + closeHistoryRetentionMenu(); } }); - document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); + historyRetentionTrigger.addEventListener('keydown', event => { + if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return; + event.preventDefault(); + openHistoryRetentionMenu(true); + }); + historyRetentionMenu.addEventListener('click', event => { + const option = event.target.closest('[data-history-retention]'); + if (option) selectHistoryRetentionOption(option.dataset.historyRetention); + }); + historyRetentionMenu.addEventListener('keydown', event => { + const options = [...historyRetentionMenu.querySelectorAll('[data-history-retention]')]; + const current = options.indexOf(document.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + closeHistoryRetentionMenu(true); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + const option = event.target.closest('[data-history-retention]'); + if (!option) return; + event.preventDefault(); + selectHistoryRetentionOption(option.dataset.historyRetention); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const next = event.key === 'Home' + ? 0 + : event.key === 'End' + ? options.length - 1 + : (Math.max(0, current) + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length; + options[next]?.focus(); + }); + historyRetentionPicker.addEventListener('focusout', () => { + window.setTimeout(() => { + if (!historyRetentionPicker.contains(document.activeElement)) closeHistoryRetentionMenu(); + }, 0); + }); + document.addEventListener('mousedown', event => { + if (!historyRetentionPicker.contains(event.target)) closeHistoryRetentionMenu(); + }); const historyRetentionSelect = document.getElementById('historyRetentionSelect'); if (historyRetentionSelect) { @@ -6083,17 +6409,22 @@ function setupListeners() { if (value !== 'all') { const preview = await window.api.pruneHistory(value, { dryRun: true }); if (preview && preview.removedRows > 0) { - const ok = confirm(`${preview.removedRows.toLocaleString('de-DE')} Verlaufseinträge werden dauerhaft entfernt.\n\nFortfahren?`); - if (!ok) { historyRetentionSelect.value = prev; return; } + const ok = confirm(`${preview.removedRows.toLocaleString(getUiLocale())} Verlaufseinträge werden dauerhaft entfernt.\n\nFortfahren?`); + if (!ok) { + historyRetentionSelect.value = prev; + syncHistoryRetentionPicker(); + return; + } } } try { const res = await runConfigWrite(() => window.api.pruneHistory(value)); config.globalSettings = { ...(config.globalSettings || {}), historyRetention: value }; - if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString('de-DE')} entfernt`); + if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString(getUiLocale())} entfernt`); loadHistory(); } catch (error) { historyRetentionSelect.value = prev; + syncHistoryRetentionPicker(); showCopyToast(error.message || String(error)); } }); @@ -6227,17 +6558,18 @@ function showUpdateBanner(info) { const title = document.getElementById('updateDialogTitle'); const message = document.getElementById('updateMessage'); const notes = document.getElementById('updateReleaseNotes'); + const notesBody = document.getElementById('updateReleaseNotesBody'); const installButton = document.getElementById('installUpdateBtn'); if (title) title.textContent = 'Eine neue Version ist verfügbar'; if (message) message.textContent = `Update v${version} verfügbar`; - if (notes) { + if (notes && notesBody) { const releaseNotes = String(info.releaseNotes || '').trim(); - notes.textContent = releaseNotes.length > 2400 ? `${releaseNotes.slice(0, 2399)}…` : releaseNotes; + notesBody.textContent = releaseNotes.length > 2400 ? `${releaseNotes.slice(0, 2399)}…` : releaseNotes; notes.hidden = !releaseNotes; } if (installButton) { installButton.disabled = false; - installButton.textContent = 'Jetzt updaten'; + installButton.textContent = 'Jetzt installieren'; } _setUpdateProgress(0, 'Bereit zum Download'); _setUpdateDialogBusy(false); @@ -6583,8 +6915,8 @@ function formatDateTime(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' }) + text: safeDate.toLocaleDateString(getUiLocale(), { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + safeDate.toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }; } diff --git a/renderer/i18n.js b/renderer/i18n.js new file mode 100644 index 0000000..b41039b --- /dev/null +++ b/renderer/i18n.js @@ -0,0 +1,587 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.I18n = api; +})(typeof window !== 'undefined' ? window : globalThis, function () { + const pairs = [ + ['Arbeitsbereich', 'Workspace'], + ['Accounts verwalten', 'Manage accounts'], + ['Archiv', 'Archive'], + ['(aktivieren zum Erzeugen)', '(enable to generate)'], + ['-Tab direkt beim jeweiligen Hoster.', ' tab for the relevant host.'], + ['127.0.0.1 oder Tunnel-/Tailscale-Adresse', '127.0.0.1 or tunnel/Tailscale address'], + ['API-Token', 'API token'], + ['Account-Status', 'Account status'], + ['Allowlist (IP/CIDR, eine pro Zeile)', 'Allowlist (one IP/CIDR per line)'], + ['Aufbewahrung', 'Retention'], + ['Ausgabe-Format der kopierten Links', 'Output format for copied links'], + ['Auto-Check vor Upload', 'Auto-check before upload'], + ['Automatischer Check vor dem Upload', 'Automatic check before upload'], + ['Bildschirm und Eingabesteuerung bleiben gesperrt.', 'Screen and input control remain locked.'], + ['Bindet nur an', 'Binds only to'], + ['Bindet an', 'Binds to'], + ['. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B.', '. Only IPs/CIDRs from the allowlist may connect (loopback is always allowed), in addition to the token. For Tailscale, enter your tailnet range, e.g.'], + [') und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.', ') and enter the Tailscale IP or MagicDNS name above as the code address. Transport is plaintext through the tunnel; Tailscale/WireGuard provides encryption.'], + ['Datum', 'Date'], + ['Einstellungskategorien', 'Settings categories'], + ['Erlaubt', 'Allowed'], + ['Erneut versuchen', 'Retry'], + ['Exportieren', 'Export'], + ['Fehlgeschlagene erneut', 'Retry failed'], + ['Fensterverhalten, Drop-Target und Programmupdates.', 'Window behavior, drop target, and application updates.'], + ['Gesamt:', 'Total:'], + ['Globales Speed-Limit', 'Global speed limit'], + ['Hauptbereiche', 'Main sections'], + ['Hinweis', 'Notice'], + ['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'], + ['Hoster', 'Host'], + ['Importieren', 'Import'], + ['In Zwischenablage', 'To clipboard'], + ['In diesem Lauf hochgeladen:', 'Uploaded during this run:'], + ['Input erlauben', 'Allow input'], + ['Installierte Version', 'Installed version'], + ['Laufzeit:', 'Runtime:'], + ['Letzte 100 Uploads', 'Last 100 uploads'], + ['Letzte 1000 Uploads', 'Last 1,000 uploads'], + ['Letzte 7 Tage', 'Last 7 days'], + ['Letzte 30 Tage', 'Last 30 days'], + ['Letzte 90 Tage', 'Last 90 days'], + ['Max. parallele Uploads', 'Max. parallel uploads'], + ['Maximale parallele Uploads', 'Maximum parallel uploads'], + ['Neustart', 'Restart'], + ['Noch keine Accounts', 'No accounts yet'], + ['Nur lokal (127.0.0.1) — Tunnel/VPN', 'Local only (127.0.0.1) — tunnel/VPN'], + ['Parallele Uploads verringern', 'Decrease parallel uploads'], + ['Restzeit:', 'Time remaining:'], + ['Rest', 'Remaining'], + ['Schnell finden', 'Quick find'], + ['Schnelleinstellungen', 'Quick settings'], + ['Sicherung', 'Backup'], + ['Sichtbarkeit', 'Visibility'], + ['Sitzung', 'Session'], + ['Speicherstatus', 'Save status'], + ['Statistik', 'Statistics'], + ['Suche Aktualisierungen', 'Checking for updates'], + ['Verbleibend:', 'Remaining:'], + ['Zuletzt erzeugte Upload-Links', 'Recently generated upload links'], + ['User-ID, role:ROLLEN-ID, @here oder @everyone', 'User ID, role:ROLE-ID, @here, or @everyone'], + ['https://discord.com/api/webhooks/… oder eigene URL', 'https://discord.com/api/webhooks/… or custom URL'], + ['nur lesenden', 'read-only'], + ['z. B. Hauptaccount, Premium, Kunde XY', 'e.g. primary account, premium, customer XY'], + ['Datei', 'File'], + ['Dateien', 'Files'], + ['Dateien hinzufügen', 'Add files'], + ['Ordner hinzufügen', 'Add folder'], + ['Online-Schlüssel erstellen', 'Create online key'], + ['Online-Schlüssel importieren', 'Import online key'], + ['Einstellungen öffnen', 'Open settings'], + ['Geschwindigkeitslimit', 'Speed limit'], + ['Hilfe', 'Help'], + ['Log-Ordner öffnen', 'Open log folder'], + ['Diagnose-Paket exportieren', 'Export diagnostics package'], + ['Eine neue Version ist verfügbar', 'A new version is available'], + ['Bereit zum Download', 'Ready to download'], + ['Abbrechen', 'Cancel'], + ['Jetzt installieren', 'Install now'], + ['Jetzt updaten', 'Install now'], + ['Alle Dateien', 'All files'], + ['Aktiv', 'Active'], + ['Warteschlange', 'Queue'], + ['Fertig', 'Completed'], + ['Fehler', 'Failed'], + ['Verfügbarkeit', 'Availability'], + ['Bereite Accounts', 'Ready accounts'], + ['Primär', 'Primary'], + ['Nicht geprüft', 'Not checked'], + ['Nicht aktiv', 'Inactive'], + ['Prüfen', 'Check'], + ['Bearbeiten', 'Edit'], + ['Keine Upload-Ziele ausgewählt', 'No upload destinations selected'], + ['Dateien ablegen, Ziele wählen und Uploads zentral steuern.', 'Drop files, choose destinations, and manage uploads in one place.'], + ['Upload-Aufträge', 'Upload jobs'], + ['Dateien hinzufügen, Ziele auswählen und Fortschritt verfolgen', 'Add files, choose destinations, and track progress'], + ['+ Dateien', '+ Files'], + ['+ Ordner', '+ Folder'], + ['Dateien hierher ziehen oder klicken', 'Drop files here or click to browse'], + ['Dateien und Ordner werden vor dem Upload geprüft.', 'Files and folders are checked before upload.'], + ['Dateiname', 'File name'], + ['Hoster / Account', 'Host / Account'], + ['Hochgeladen / Größe', 'Uploaded / Size'], + ['Geschwindigkeit', 'Speed'], + ['Fortschritt', 'Progress'], + ['Status', 'Status'], + ['Aktionen', 'Actions'], + ['Alle Links kopieren', 'Copy all links'], + ['Alle entfernen', 'Remove all'], + ['Entfernen', 'Remove'], + ['Dateien in der Warteschlange', 'Files in queue'], + ['Läuft:', 'Running:'], + ['Fehler:', 'Failed:'], + ['Dateigröße in der Warteschlange', 'Queued file size'], + ['Upload-Geschwindigkeit:', 'Upload speed:'], + ['Zugänge', 'Accounts'], + ['Alle Accounts', 'All accounts'], + ['Bereit', 'Ready'], + ['Aktion nötig', 'Action required'], + ['Zugänge prüfen, priorisieren und für Uploads bereitstellen.', 'Check, prioritize, and prepare accounts for uploads.'], + ['Hoster-Zugangsdaten verwalten und prüfen', 'Manage and verify host account credentials'], + ['Accounts prüfen', 'Check accounts'], + ['Account hinzufügen', 'Add account'], + ['Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.', 'Add your first host account. Credentials are verified before saving.'], + ['Alle ausklappen', 'Expand all'], + ['Alle einklappen', 'Collapse all'], + ['Wähle einen Hoster und gib deine Zugangsdaten ein.', 'Choose a host and enter your credentials.'], + ['Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.', 'Choose a host and enter your credentials. The account is checked before it is added.'], + ['Prüfen und anlegen', 'Verify and add'], + ['Prüfen und speichern', 'Verify and save'], + ['Keine Einträge.', 'No entries.'], + ['Schließen', 'Close'], + ['Account löschen?', 'Delete account?'], + ['Account wirklich löschen?', 'Are you sure you want to delete this account?'], + ['Löschen', 'Delete'], + ['Alle Optionen nach Aufgaben sortiert. Änderungen werden mit dem Speichern-Button übernommen.', 'All options are grouped by task. Changes are applied with the Save button.'], + ['Speichern', 'Save'], + ['Einstellungen', 'Settings'], + ['Allgemein', 'General'], + ['Uploads', 'Uploads'], + ['Automatik', 'Automation'], + ['Benachrichtigungen', 'Notifications'], + ['Logs & Support', 'Logs & Support'], + ['Fernsteuerung', 'Remote control'], + ['Diagnose-Zugriff', 'Diagnostics access'], + ['Backup & Übertragen', 'Backup & Transfer'], + ['Keine passende Einstellung gefunden.', 'No matching setting found.'], + ['Keine ungespeicherten Änderungen', 'No unsaved changes'], + ['Oberfläche', 'Interface'], + ['Sprache', 'Language'], + ['Englisch', 'English'], + ['Deutsch', 'German'], + ['Immer im Vordergrund', 'Always on top'], + ['Drop-Target anzeigen', 'Show drop target'], + ['Programmupdate', 'Application update'], + ['Nach neuer Version suchen', 'Check for a newer version'], + ['Verfügbare Updates werden zusammen mit dem Changelog angezeigt.', 'Available updates are shown together with the changelog.'], + ['Neue Version suchen', 'Check for a new version'], + ['Nach Updates suchen', 'Check for updates'], + ['Upload-Verhalten', 'Upload behavior'], + ['Globale Leistung, Warteschlange und Verhalten nach einem erfolgreichen Upload.', 'Global performance, queue, and behavior after a successful upload.'], + ['Leistung', 'Performance'], + ['Globale parallele Uploads', 'Global parallel uploads'], + ['0 = nur die Einstellung des jeweiligen Hosters verwenden', '0 = use the setting of each host'], + ['Verteilt die globale Parallelität auf vorhandene Accounts eines Hosters.', 'Distributes global parallelism across the available accounts of a host.'], + ['Nach Abschluss', 'After completion'], + ['Nach Abschluss aus der Liste entfernen', 'Remove from the list after completion'], + ['Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.', 'Successfully uploaded files are automatically removed from the upload list.'], + ['Warteschlange beim Start wiederherstellen', 'Restore queue on startup'], + ['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'], + ['Hoster-Einstellungen', 'Host settings'], + ['Upload-Einstellungen', 'Upload settings'], + ['Erfolgreiche Links in fileuploader.log.', 'Successful links in fileuploader.log.'], + ['Verteilt die Dateien reihum auf alle aktiven Accounts dieses Hosters (Datei 1 → Account 1, Datei 2 → Account 2 …). Hält z. B. byse-Accounts aktiv. Nur ein Account = kein Effekt.', 'Distributes files across all active accounts for this host in round-robin order (file 1 → account 1, file 2 → account 2, and so on). Keeps accounts such as byse active. Has no effect with only one account.'], + ['Größen-Limit merken', 'Remember size limit'], + ['Überspringt nach zwei verdächtigen Ablehnungen auf einem Account größere Dateien dort vorab ("Bekanntes Größen-Limit"). Abschalten = jede Datei wird immer wirklich versucht.', 'After two suspicious rejections on an account, larger files are skipped there in advance ("Known size limit"). When disabled, every file is always attempted.'], + ['Einstellungen einzelner Hoster', 'Settings for individual hosts'], + ['wie Wiederholungen, Geschwindigkeit, Parallelität, Dateigröße und Logging findest du im', 'such as retries, speed, parallelism, file size, and logging are available in'], + ['Accounts-Tab.', 'the Accounts tab.'], + ['Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.', 'Retries and watched folders for unattended uploads.'], + ['Unbeaufsichtigter Betrieb', 'Unattended operation'], + ['Automatische Wiederholungsrunden', 'Automatic retry rounds'], + ['0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht.', '0 = off. After a batch ends, transient errors (network or host issues) are retried automatically for up to N rounds.'], + ['Wartezeit zwischen Runden', 'Delay between rounds'], + ['Minuten · jede weitere Runde wartet entsprechend länger', 'Minutes · each additional round waits proportionally longer'], + ['Ordnerüberwachung', 'Folder monitoring'], + ['Inaktiv', 'Inactive'], + ['Ordnerpfad', 'Folder path'], + ['Wählen', 'Choose'], + ['Dateierweiterungen', 'File extensions'], + ['Nur diese', 'Only these'], + ['Alle außer', 'All except'], + ['Verzögerung (Sekunden)', 'Delay (seconds)'], + ['Warten bis Datei fertig geschrieben', 'Wait until the file has finished writing'], + ['Verhalten', 'Behavior'], + ['Aktiviert', 'Enabled'], + ['Unterordner einbeziehen', 'Include subfolders'], + ['Duplikate überspringen', 'Skip duplicates'], + ['Auto-Upload starten', 'Start uploads automatically'], + ['Hoster-Vorauswahl', 'Host preselection'], + ['Erst Accounts anlegen, dann hier auswählen.', 'Add accounts first, then select them here.'], + ['Keine Auswahl = Hoster-Modal bei jeder Datei.', 'No selection = show the host dialog for every file.'], + ['Meldungen nach einem abgeschlossenen Upload-Batch versenden.', 'Send notifications after a completed upload batch.'], + ['Webhook-Adresse', 'Webhook URL'], + ['Test senden', 'Send test'], + ['Nach Batch-Ende wird eine Zusammenfassung versendet. Discord wird automatisch erkannt, andere Ziele erhalten JSON.', 'A summary is sent after the batch ends. Discord is detected automatically; other destinations receive JSON.'], + ['Discord-Erwähnung', 'Discord mention'], + ['Optional · leer lassen, wenn die Nachricht ohne Ping gesendet werden soll', 'Optional · leave blank to send the message without a ping'], + ['Protokollierung verwalten, Log-Dateien öffnen und ein bereinigtes Support-Paket erstellen.', 'Manage logging, open log files, and create a sanitized support package.'], + ['Ordner wählen', 'Choose folder'], + ['Öffnen', 'Open'], + ['Log-Datei-Modus', 'Log file mode'], + ['Eine Datei', 'Single file'], + ['Pro Tag', 'Per day'], + ['Pro Session', 'Per session'], + ['Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.', 'Per session = a new file on every application start; a new session begins after fully closing and reopening the application.'], + ['DEBUG-Einträge in debug.log schreiben (Performance ↓, Diagnostik ↑)', 'Write DEBUG entries to debug.log (performance ↓, diagnostics ↑)'], + ['Diagnose', 'Diagnostics'], + ['Log-Dateien', 'Log files'], + ['Wird geladen…', 'Loading…'], + ['Zeigen', 'Show'], + ['Im Explorer zeigen', 'Show in File Explorer'], + ['Support-Paket', 'Support package'], + ['Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.', 'A .txt containing logs and sanitized configuration; passwords and API keys are masked before saving.'], + ['Upload-Funktionen über einen verbundenen Client steuern.', 'Control upload features through a connected client.'], + ['Server', 'Server'], + ['Eingaben erlauben', 'Allow input'], + ['Kopieren', 'Copy'], + ['Neu', 'New'], + ['Prüfe...', 'Checking...'], + ['Prüfe…', 'Checking…'], + ['Zeitlich kontrollierter Lesezugriff für Fehleranalyse und Support.', 'Time-limited read access for troubleshooting and support.'], + ['Diagnose-Zugriff (nur lesen)', 'Diagnostics access (read-only)'], + ['Zugriff auf Logs, Queue-Status und bereinigte Einstellungen. Passwörter, API-Keys und Tokens werden maskiert.', 'Access to logs, queue status, and sanitized settings. Passwords, API keys, and tokens are masked.'], + ['Der Verbindungs-Code ist ein Zugangsschlüssel — nur mit vertrauenswürdigen Stellen teilen; bei Verdacht „Neu" klicken. Standard-Bindung ist', 'The connection code is an access key. Share it only with trusted parties and click “New” if you suspect misuse. The default binding is'], + ['und damit nur über einen SSH- oder VPN-Tunnel erreichbar.', 'and is therefore reachable only through an SSH or VPN tunnel.'], + ['Im Netzwerk (0.0.0.0) — Allowlist nötig', 'On the network (0.0.0.0) — allowlist required'], + ['Adresse für den Code', 'Address for the code'], + ['Verbindungs-Code', 'Connection code'], + ['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH) — die sicherste Variante.', '. Remote access is available only through a tunnel such as Tailscale or SSH, which is the safest option.'], + ['Accounts und Einstellungen sichern oder auf ein anderes Gerät übernehmen.', 'Back up accounts and settings or transfer them to another device.'], + ['Der Upload-Verlauf wird nicht übertragen und bleibt auf diesem Gerät.', 'Upload history is not transferred and remains on this device.'], + ['Verschlüsseltes Online-Backup', 'Encrypted online backup'], + ['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'], + ['Neuen Schlüssel erzeugen', 'Generate new key'], + ['Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.', 'Each export creates a new key. Older keys remain valid.'], + ['Dein neuer Schlüssel', 'Your new key'], + ['Vorhandenen Schlüssel importieren', 'Import existing key'], + ['Online importieren', 'Import online'], + ['Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.', 'Treat the key like a password. Anyone who has it can decrypt the encrypted settings.'], + ['Lokales Datei-Backup', 'Local file backup'], + ['Datei exportieren', 'Export file'], + ['Datei importieren', 'Import file'], + ['Alle Uploads', 'All uploads'], + ['Erfolgreich', 'Successful'], + ['Aktive Regel', 'Active rule'], + ['Alles behalten', 'Keep everything'], + ['Links wiederfinden, kopieren oder als Datei exportieren.', 'Find links, copy them, or export them as a file.'], + ['Upload-Verlauf', 'Upload history'], + ['Verlauf exportieren', 'Export history'], + ['Verlauf löschen', 'Delete history'], + ['Verlauf löschen?', 'Delete history?'], + ['Alle Verlaufseinträge werden dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', 'All history entries will be permanently deleted. This action cannot be undone.'], + ['Ausgewählte starten', 'Start selected'], + ['Upload-Ziele auswählen', 'Choose upload destinations'], + ['Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.', 'Files were added. Now choose the hosts for the upload.'], + ['Alle', 'All'], + ['Keine', 'None'], + ['Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.', 'No hosts with credentials are available. Add a login or API key under Accounts first.'], + ['Keine Hoster mit Zugangsdaten für einen Check.', 'No hosts with credentials are available for a check.'], + ['Die Auswahl wird für neue Queue-Einträge verwendet.', 'The selection is used for new queue entries.'], + ['Nach Aktualisierungen suchen', 'Check for updates'], + ['Anwendungsmenüs', 'Application menus'], + ['Parallele Uploads erhöhen', 'Increase parallel uploads'], + ['Geschwindigkeitslimit aktivieren', 'Enable speed limit'], + ['Geschwindigkeitslimit in Megabyte pro Sekunde', 'Speed limit in megabytes per second'], + ['Geschwindigkeitslimit erhöhen', 'Increase speed limit'], + ['Geschwindigkeitslimit verringern', 'Decrease speed limit'], + ['Hilfe und Support', 'Help and support'], + ['Update-Fortschritt', 'Update progress'], + ['Alle Dateien anzeigen', 'Show all files'], + ['Aktive Uploads anzeigen', 'Show active uploads'], + ['Wartende Uploads anzeigen', 'Show queued uploads'], + ['Fertige Uploads anzeigen', 'Show completed uploads'], + ['Fehlgeschlagene Uploads anzeigen', 'Show failed uploads'], + ['Alle Uploads starten', 'Start all uploads'], + ['Ausgewählte Uploads starten', 'Start selected uploads'], + ['Ausgewählte Datei erneut hochladen', 'Upload selected file again'], + ['Ausgewählten Upload abbrechen', 'Cancel selected upload'], + ['Aktive Uploads beenden und stoppen', 'Stop active uploads'], + ['Alle Uploads abbrechen', 'Cancel all uploads'], + ['Nach oben', 'Move up'], + ['Nach unten', 'Move down'], + ['Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)', 'Export all rows as a file (time, host, link, file name)'], + ['Alle Links aus diesem Panel entfernen', 'Remove all links from this panel'], + ['Alle Accounts anzeigen', 'Show all accounts'], + ['Bereite Accounts anzeigen', 'Show ready accounts'], + ['Fehlerhafte Accounts anzeigen', 'Show accounts with errors'], + ['Einstellungen durchsuchen', 'Search settings'], + ['Ordner wählen...', 'Choose folder...'], + ['Standardpfad verwenden', 'Use default path'], + ['Log-Ordner im Explorer öffnen', 'Open log folder in File Explorer'], + ['Sammelt alle Logs + sanitierte Config (Credentials maskiert) + App-Versionen in eine einzelne .txt-Datei zum Teilen.', 'Collects all logs, sanitized configuration with masked credentials, and application versions in a single shareable .txt file.'], + ['Neu generieren', 'Generate new'], + ['Neu generieren (macht alte Codes ungültig)', 'Generate new (invalidates old codes)'], + ['Nach dem Export erscheint hier der 75-stellige Schlüssel', 'The 75-character key appears here after export'], + ['Verlaufsübersicht', 'History overview'], + ['Verlaufsstatus', 'History status'], + ['Gesamten Verlauf anzeigen', 'Show all history'], + ['Erfolgreiche Uploads anzeigen', 'Show successful uploads'], + ['Fehlgeschlagene Uploads anzeigen', 'Show failed uploads'], + ['Gespeichert!', 'Saved!'], + ['Speichert…', 'Saving…'], + ['Changelog', 'Changelog'], + ['— bitte Diagnose-Paket exportieren oder Programm neu starten.', '— please export a diagnostics package or restart the application.'], + ['• Bereit', '• Ready'], + ['• Fehler', '• Failed'], + ['