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() { @@ -3758,6 +3809,24 @@ function renderSettings() { pages.allgemein.innerHTML = ` ${pageHeader('Allgemein', 'Fensterverhalten, Drop-Target und Programmupdates.')}
Oberfläche
+
+ +
+ + + +
+ +
@@ -3769,8 +3838,11 @@ function renderSettings() {
Programmupdate
-
- +
+
+ Nach neuer Version suchen + Verfügbare Updates werden zusammen mit dem Changelog angezeigt. +
`; @@ -3898,7 +3970,7 @@ function renderSettings() { pages.logs.innerHTML = ` ${pageHeader('Logs & Support', 'Protokollierung verwalten, Log-Dateien öffnen und ein bereinigtes Support-Paket erstellen.')}
Log
-
+
@@ -4047,6 +4119,8 @@ function renderSettings() { const activateSettingsPage = (target, focus = false) => { const activeButton = navigation.querySelector(`[data-settings-page="${target}"]`); if (!activeButton || activeButton.hidden) return; + const indicator = navigation.querySelector('.settings-nav-indicator'); + if (indicator) indicator.hidden = false; navigation.querySelectorAll('.settings-nav-button').forEach((button) => { const active = button === activeButton; button.classList.toggle('active', active); @@ -4055,6 +4129,7 @@ function renderSettings() { Object.values(pages).forEach((page) => { page.classList.toggle('active', page.dataset.subpage === target); }); + _syncSidebarIndicator(activeButton); if (focus) activeButton.focus(); content.scrollTop = 0; }; @@ -4081,7 +4156,7 @@ function renderSettings() { const searchInput = layout.querySelector('#settingsSearchInput'); const searchEmpty = layout.querySelector('#settingsSearchEmpty'); searchInput.addEventListener('input', () => { - const query = searchInput.value.trim().toLocaleLowerCase('de-DE'); + const query = searchInput.value.trim().toLocaleLowerCase(getUiLocale()); const visibleButtons = []; navigation.querySelectorAll('.settings-nav-button').forEach((button) => { const visible = !query || button.dataset.search.includes(query); @@ -4092,10 +4167,14 @@ function renderSettings() { const activeButton = navigation.querySelector('.settings-nav-button.active'); if (visibleButtons.length === 0) { Object.values(pages).forEach((page) => page.classList.remove('active')); + const indicator = navigation.querySelector('.settings-nav-indicator'); + if (indicator) indicator.hidden = true; return; } if (!activeButton || activeButton.hidden || !content.querySelector('.settings-subpage.active')) { activateSettingsPage((activeButton && !activeButton.hidden ? activeButton : visibleButtons[0]).dataset.settingsPage); + } else { + _syncSidebarIndicator(activeButton, true); } }); @@ -4218,7 +4297,7 @@ function renderSettings() { const fmtIssued = (ts) => { if (!ts) return ''; - try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; } + try { return 'Code erstellt: ' + new Date(ts).toLocaleString(getUiLocale()); } catch { return ''; } }; const parseAllowlist = () => allowlistEl.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const renderModeUi = (suggestedHosts) => { @@ -4264,7 +4343,7 @@ function renderSettings() { const el = document.getElementById('diagConnectionStatus'); if (!el || !st) return; if (st.running) { - const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—'; + const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString(getUiLocale()) : '—'; const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal'; el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} Client(s) — Letzter Zugriff: ${last}`; el.style.color = '#10b981'; @@ -4336,8 +4415,37 @@ function renderSettings() { document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); _syncHeaderUpdateState(); container.querySelectorAll('.settings-autosave').forEach((input) => { - const eventName = input.type === 'checkbox' ? 'change' : 'input'; - input.addEventListener(eventName, scheduleSettingsSave); + const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, () => { + if (input.id === 'languageInput') { + setUiLanguage(input.value); + syncLanguagePicker(input.value); + } + markSettingsDirty(); + }); + }); + container.querySelectorAll('.language-option').forEach(button => { + button.addEventListener('click', () => { + const input = document.getElementById('languageInput'); + if (!input || input.value === button.dataset.language) return; + input.value = button.dataset.language; + input.dispatchEvent(new window.Event('change', { bubbles: true })); + }); + }); + syncLanguagePicker(globalSettings.language); + establishSettingsBaseline(); + window.requestAnimationFrame(() => { + if (layout.isConnected) _syncSidebarIndicator(navigation.querySelector('.settings-nav-button.active'), true); + }); +} + +function syncLanguagePicker(value) { + const language = window.I18n.normalizeLanguage(value); + const picker = document.getElementById('languagePicker'); + if (!picker) return; + picker.dataset.language = language; + picker.querySelectorAll('.language-option').forEach(button => { + button.setAttribute('aria-pressed', String(button.dataset.language === language)); }); } @@ -4346,24 +4454,66 @@ async function chooseLogFilePath() { if (!folders || !folders[0]) return; const normalized = folders[0].replace(/[\\\/]+$/, ''); document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; - scheduleSettingsSave(); + markSettingsDirty(); +} + +function captureSettingsState() { + const controls = Array.from(document.querySelectorAll('#settings-view .settings-autosave')); + return JSON.stringify(controls.map(control => [ + control.id || control.name || '', + control.type === 'checkbox' ? control.checked : control.value + ])); +} + +function syncSettingsSaveState(message) { + const button = document.getElementById('saveSettingsBtn'); + const feedback = document.getElementById('saveFeedback'); + if (button) { + button.disabled = settingsSaving || !settingsDirty; + button.classList.toggle('btn-success', settingsDirty && !settingsSaving); + button.classList.toggle('btn-secondary', !settingsDirty || settingsSaving); + } + if (feedback && message) feedback.textContent = message; +} + +function establishSettingsBaseline(message = 'Keine ungespeicherten Änderungen') { + settingsBaseline = captureSettingsState(); + settingsDirty = false; + syncSettingsSaveState(message); +} + +function markSettingsDirty() { + settingsDirty = captureSettingsState() !== settingsBaseline; + syncSettingsSaveState(settingsDirty ? 'Ungespeicherte Änderungen' : 'Keine ungespeicherten Änderungen'); } function scheduleSettingsSave() { - if (closePreparationState !== 'open') return; - const feedback = document.getElementById('saveFeedback'); - if (feedback) feedback.textContent = 'Speichert...'; - clearTimeout(settingsSaveTimer); - settingsSaveTimer = setTimeout(() => { - settingsSaveTimer = null; - saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { - if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; - }); - }, 350); + markSettingsDirty(); } function saveSettings(options = {}) { - return settingsSaveCoordinator.run(options); + const requestedState = captureSettingsState(); + settingsSaving = true; + syncSettingsSaveState('Speichert…'); + return settingsSaveCoordinator.run(options).then(result => { + const savedMessage = options.feedbackText || 'Gespeichert!'; + settingsBaseline = requestedState; + settingsDirty = captureSettingsState() !== settingsBaseline; + syncSettingsSaveState(settingsDirty ? 'Ungespeicherte Änderungen' : savedMessage); + setTimeout(() => { + const feedback = document.getElementById('saveFeedback'); + if (!settingsDirty && feedback?.textContent === savedMessage) syncSettingsSaveState('Keine ungespeicherten Änderungen'); + }, 1800); + return result; + }, error => { + settingsSaving = false; + settingsDirty = captureSettingsState() !== settingsBaseline; + syncSettingsSaveState(`Speichern fehlgeschlagen: ${error.message}`); + throw error; + }).finally(() => { + settingsSaving = false; + syncSettingsSaveState(); + }); } async function performSaveSettings(options = {}) { @@ -4383,6 +4533,10 @@ async function performSaveSettings(options = {}) { const globalSettings = { ...cur, + language: (() => { + const el = document.getElementById('languageInput'); + return window.I18n.normalizeLanguage(el ? el.value : cur.language); + })(), logFilePath: elTxt('logFilePathInput', cur.logFilePath || '').trim(), logMode: (() => { const el = document.getElementById('logModeInput'); @@ -4519,12 +4673,7 @@ async function performSaveSettings(options = {}) { } const feedback = document.getElementById('saveFeedback'); - feedback.textContent = feedbackText; - setTimeout(() => { - if (feedback.textContent === feedbackText) { - feedback.textContent = 'Änderungen werden automatisch gespeichert.'; - } - }, 1800); + if (feedback) feedback.textContent = feedbackText; } // --- Accounts --- @@ -5489,6 +5638,112 @@ function _hideOtpField() { } // --- History --- +let historyRetentionMenuToken = 0; + +function syncHistoryRetentionPicker() { + const select = document.getElementById('historyRetentionSelect'); + const value = document.getElementById('historyRetentionValue'); + const menu = document.getElementById('historyRetentionMenu'); + if (!select || !value || !menu) return; + const labels = { + all: 'Alles behalten', + '7d': 'Letzte 7 Tage', + '30d': 'Letzte 30 Tage', + '90d': 'Letzte 90 Tage', + '1000': 'Letzte 1000 Uploads', + '100': 'Letzte 100 Uploads' + }; + value.textContent = labels[select.value] || labels.all; + menu.querySelectorAll('[data-history-retention]').forEach(option => { + const selected = option.dataset.historyRetention === select.value; + option.setAttribute('aria-selected', String(selected)); + option.tabIndex = selected ? 0 : -1; + }); +} + +function openHistoryRetentionMenu(focusOption = false) { + const trigger = document.getElementById('historyRetentionTrigger'); + const menu = document.getElementById('historyRetentionMenu'); + if (!trigger || !menu) return; + historyRetentionMenuToken++; + menu.classList.remove('menu-closing', 'menu-opening'); + menu.style.display = 'block'; + void menu.offsetHeight; + menu.classList.add('menu-opening'); + trigger.setAttribute('aria-expanded', 'true'); + if (focusOption) { + window.requestAnimationFrame(() => menu.querySelector('[aria-selected="true"]')?.focus()); + } +} + +function closeHistoryRetentionMenu(returnFocus = false) { + const trigger = document.getElementById('historyRetentionTrigger'); + const menu = document.getElementById('historyRetentionMenu'); + if (!trigger || !menu || window.getComputedStyle(menu).display === 'none') return; + const token = ++historyRetentionMenuToken; + menu.classList.remove('menu-opening', 'menu-closing'); + void menu.offsetHeight; + menu.classList.add('menu-closing'); + trigger.setAttribute('aria-expanded', 'false'); + const finish = () => { + if (!Object.is(historyRetentionMenuToken, token)) return; + menu.style.display = 'none'; + menu.classList.remove('menu-closing'); + if (returnFocus) trigger.focus(); + }; + menu.addEventListener('animationend', finish, { once: true }); + window.setTimeout(finish, 220); +} + +function selectHistoryRetentionOption(value) { + const select = document.getElementById('historyRetentionSelect'); + if (!select || !select.querySelector(`option[value="${value}"]`)) return; + select.value = value; + syncHistoryRetentionPicker(); + closeHistoryRetentionMenu(true); + select.dispatchEvent(new window.Event('change', { bubbles: true })); +} + +function syncHistoryClearAction() { + const button = document.getElementById('clearHistoryBtn'); + if (button) button.disabled = historyRowsData.length === 0; +} + +function closeHistoryClearModal() { + const modal = document.getElementById('historyClearModal'); + if (!modal) return; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.getElementById('clearHistoryBtn')?.focus(); +} + +function openHistoryClearModal() { + const button = document.getElementById('clearHistoryBtn'); + const modal = document.getElementById('historyClearModal'); + if (!modal || !button || button.disabled) return; + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + document.getElementById('confirmHistoryClearBtn')?.focus(); +} + +async function confirmHistoryClear() { + const confirmButton = document.getElementById('confirmHistoryClearBtn'); + const cancelButton = document.getElementById('cancelHistoryClearBtn'); + if (!confirmButton || confirmButton.disabled) return; + confirmButton.disabled = true; + cancelButton.disabled = true; + try { + await runConfigWrite(() => window.api.clearHistory()); + await loadHistory(); + closeHistoryClearModal(); + } catch (error) { + showCopyToast(error.message || String(error)); + } finally { + confirmButton.disabled = false; + cancelButton.disabled = false; + } +} + async function loadHistory() { const history = await window.api.getHistory(); window._historyForStats = history || []; @@ -5496,13 +5751,17 @@ async function loadHistory() { _historyDirty = false; _invalidateHosterLifetimeCache(); const retSel = document.getElementById('historyRetentionSelect'); - if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; + if (retSel) { + retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; + syncHistoryRetentionPicker(); + } const container = document.getElementById('historyContainer'); if (!history || history.length === 0) { historyRowsData = []; historySidebarCounts = { total: 0, success: 0, error: 0 }; updateHistorySidebarSummary(); + syncHistoryClearAction(); container.innerHTML = '

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'], + ['', ''], + ['Noch keine Uploads.', 'No uploads yet.'], + ['Pfade nicht verfügbar.', 'Paths unavailable.'], + ['Noch keine Uploads in dieser Session.', 'No uploads in this session yet.'], + ['Account bearbeiten', 'Edit account'], + ['Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.', 'The account is no longer in the configuration because it was deleted externally. Close the dialog and add it again.'], + ['Account wurde erfolgreich geprüft und gespeichert.', 'Account verified and saved successfully.'], + ['Account wurde mit Warnung geprüft und gespeichert.', 'Account verified and saved with a warning.'], + ['Account-Check läuft bereits.', 'An account check is already running.'], + ['Account-Löschung konnte nicht persistiert werden — bitte erneut versuchen.', 'The account deletion could not be saved. Please try again.'], + ['Account-Übersicht', 'Account overview'], + ['Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.', 'The account was marked as failed for this session. Click to reactivate it.'], + ['Pausiert (Session)', 'Paused (session)'], + ['Wieder aktivieren', 'Reactivate'], + ['Accounts mit Handlungsbedarf anzeigen', 'Show accounts requiring action'], + ['Alle Accounts und Einstellungen wurden übernommen.', 'All accounts and settings were applied.'], + ['Anwendung neu starten?', 'Restart application?'], + ['API-Key anzeigen', 'Show API key'], + ['Automatisch gespeichert', 'Saved automatically'], + ['Beenden', 'Quit'], + ['Bekanntes Größen-Limit', 'Known size limit'], + ['Backup nicht entschlüsselbar', 'Backup could not be decrypted'], + ['Bitte mindestens einen Hoster auswählen.', 'Select at least one host.'], + ['CSRF token nicht gefunden', 'CSRF token not found'], + ['Deaktiviert', 'Disabled'], + ['Der Schlüssel muss exakt 75 Zeichen lang sein.', 'The key must be exactly 75 characters long.'], + ['Der Schlüssel muss mit MHU2- beginnen und exakt 75 Zeichen lang sein.', 'The key must start with MHU2- and be exactly 75 characters long.'], + ['Diagnose-Paket wird erstellt…', 'Creating diagnostics package…'], + ['Die Anwendung konnte nach dem fehlgeschlagenen Speichern nicht entsperrt werden', 'The application could not be unlocked after saving failed'], + ['Die Anwendung wird gerade beendet', 'The application is shutting down'], + ['Download wird geprüft…', 'Verifying download…'], + ['Download wird vorbereitet…', 'Preparing download…'], + ['Ein Einstellungsimport läuft bereits', 'A settings import is already running'], + ['Einstellungen konnten vor dem Beenden nicht gespeichert werden', 'Settings could not be saved before quitting'], + ['Einstellungen werden gerade importiert', 'Settings are being imported'], + ['Einstellungen werden gespeichert…', 'Saving settings…'], + ['Einstellungen wurden durch einen Import ersetzt', 'Settings were replaced by an import'], + ['Ganz nach oben', 'Move to top'], + ['Ganz nach unten', 'Move to bottom'], + ['Hoster entfernen ▸', 'Remove host ▸'], + ['Kein gültiger Account', 'No valid account'], + ['Kein Update verfügbar', 'No update available'], + ['Kein Upload aktiv', 'No active upload'], + ['Kein Verlauf zum Exportieren vorhanden.', 'There is no history to export.'], + ['Keine Antwort vom Hoster erhalten', 'No response received from the host'], + ['Keine Einträge im Log gefunden', 'No log entries found'], + ['Keine Einträge zum Exportieren.', 'No entries to export.'], + ['Keine Jobs hinzugefuegt', 'No jobs added'], + ['Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).', 'No log entries for this job. Nothing has happened yet, or entries from a previous batch were already cleared.'], + ['Keine passenden Jobs für Retry gefunden.', 'No matching jobs found for retry.'], + ['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'], + ['Keine transienten Fehler', 'No transient errors'], + ['Keine URL eingetragen.', 'No URL entered.'], + ['Keine Zugangsdaten', 'No credentials'], + ['Lade und entschlüssele Einstellungen…', 'Loading and decrypting settings…'], + ['Link kopieren', 'Copy link'], + ['Links kopieren', 'Copy links'], + ['Log anzeigen', 'Show log'], + ['Log importieren', 'Import log'], + ['Log importieren — bereits hochgeladene aus Queue entfernen', 'Import log — remove already uploaded jobs from queue'], + ['Log-Datei', 'Log file'], + ['MB/s · 0 = unbegrenzt', 'MB/s · 0 = unlimited'], + ['Log-Pfad automatisch auf funktionierenden Ordner gesetzt', 'Log path automatically changed to a writable folder'], + ['Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'New key created. Older keys remain valid.'], + ['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'], + ['Online-Schlüssel erstellt', 'Online key created'], + ['Online-Schlüssel kopiert', 'Online key copied'], + ['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'], + ['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'], + ['OTP wird geprüft…', 'Checking OTP…'], + ['OTP-Prüfung fehlgeschlagen', 'OTP check failed'], + ['Passwort anzeigen', 'Show password'], + ['Prüfe Zugangsdaten…', 'Checking credentials…'], + ['Prüfen…', 'Checking…'], + ['Prüfung fehlgeschlagen', 'Check failed'], + ['Prüfung oder Speichern fehlgeschlagen', 'Check or save failed'], + ['Speichere aktuelle Einstellungen…', 'Saving current settings…'], + ['Speichern vor dem Beenden hat zu lange gedauert', 'Saving before quitting took too long'], + ['Stoppt nach aktiven Uploads...', 'Stopping after active uploads...'], + ['Suche nach Aktualisierungen…', 'Checking for updates…'], + ['Suche nach Updates…', 'Checking for updates…'], + ['System wird heruntergefahren in', 'System will shut down in'], + ['Übernehmen', 'Apply'], + ['Übersprungen', 'Skipped'], + ['Ungespeicherte Änderungen', 'Unsaved changes'], + ['Update installiert. Die Anwendung startet neu…', 'Update installed. The application is restarting…'], + ['Update konnte nicht gestartet werden', 'Update could not be started'], + ['Update verfügbar', 'Update available'], + ['Update ist bereit. Einstellungen werden gespeichert…', 'The update is ready. Saving settings…'], + ['Update wird installiert. Die Anwendung startet neu…', 'Installing update. The application is restarting…'], + ['Updateprüfung fehlgeschlagen', 'Update check failed'], + ['Upload läuft...', 'Uploading...'], + ['Upload-Log', 'Upload log'], + ['Upload-Status', 'Upload status'], + ['Upload-Übersicht', 'Upload overview'], + ['Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON', 'Export history as CSV?\n\nOK = CSV\nCancel = JSON'], + ['Verlauf wirklich löschen?', 'Are you sure you want to delete the history?'], + ['Verschlüssele und speichere Einstellungen…', 'Encrypting and saving settings…'], + ['Zeit', 'Time'], + ['Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.', 'If the backup was created with the legacy password option before v3.0, enter it here.'], + ['Verlauf', 'History'] + ]; + const deToEn = new Map(pairs); + const enToDe = new Map(pairs.map(([de, en]) => [en, de])); + + function normalizeLanguage(value) { + return value === 'de' ? 'de' : 'en'; + } + + function translateText(value, language) { + const text = String(value ?? ''); + const target = normalizeLanguage(language); + const leading = text.match(/^\s*/)?.[0] || ''; + const trailing = text.match(/\s*$/)?.[0] || ''; + const core = text.slice(leading.length, text.length - trailing.length); + const exact = target === 'en' ? deToEn.get(core) : enToDe.get(core); + if (exact) return `${leading}${exact}${trailing}`; + const patterns = target === 'en' + ? [ + [/^Update v(.+) verfügbar$/, 'Update v$1 available'], + [/^Aktives Ziel: (.+)$/, 'Active destination: $1'], + [/^(\d+) Fehler$/, '$1 errors'], + [/^Gesamt (\d+)$/, 'Total $1'], + [/^Verbindungen (\d+)$/, 'Connections $1'], + [/^Verbleibend (\d+)$/, 'Remaining $1'], + [/^(\d+) deaktiviert$/, '$1 disabled'], + [/^(.+) verbergen$/, 'Hide $1'], + [/^(.+) anzeigen$/, 'Show $1'], + [/^(.+) Account wieder aktiv — nächste Batch verwendet ihn$/, '$1 account reactivated — the next batch will use it'], + [/^(.+): Alle Accounts ausgeschöpft$/, '$1: all accounts exhausted'], + [/^(.+): Keine weiteren Fallback-Accounts verfügbar$/, '$1: no additional fallback accounts available'], + [/^(\d+) Ziele aktiv: (.+)$/, '$1 destinations active: $2'], + [/^(\d+) Job\(s\) zum erneuten Upload zurückgesetzt$/, '$1 job(s) reset for upload'], + [/^(\d+) bereits hochgeladene Jobs aus Queue entfernt \((\d+) Log-Einträge gelesen\)$/, '$1 already uploaded jobs removed from the queue ($2 log entries read)'], + [/^(\d+) Einträge exportiert$/, '$1 entries exported'], + [/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.\n\nFortfahren\?$/, '$1 history entries will be permanently removed.\n\nContinue?'], + [/^Account "(.+)" wirklich löschen\?$/, 'Are you sure you want to delete account "$1"?'], + [/^Aktiv auf (.+) \((.+)\) — (\d+) Client\(s\) — Letzter Zugriff: (.+)$/, 'Active at $1 ($2) — $3 client(s) — Last access: $4'], + [/^Aktiv auf Port (\d+) — (\d+) Client\(s\) verbunden$/, 'Active on port $1 — $2 client(s) connected'], + [/^Alle Fehler erneut versuchen \((\d+)\)$/, 'Retry all failed uploads ($1)'], + [/^API: (.+)$/, 'API: $1'], + [/^Ausgewählte starten \((\d+)\)$/, 'Start selected ($1)'], + [/^Auto-Retry Runde (\d+)\/(\d+): (\d+) transiente Fehler werden in (.+) min neu versucht\.$/, 'Auto-retry round $1/$2: $3 transient errors will be retried in $4 min.'], + [/^Backup importiert\. Bitte prüfen: (.+)\.$/, 'Backup imported. Please review: $1.'], + [/^Code erstellt: (.+)$/, 'Code created: $1'], + [/^Diagnose-Paket gespeichert \((.+) KB\)$/, 'Diagnostics package saved ($1 KB)'], + [/^Einstellungen übernommen\. Bitte prüfen: (.+)\.$/, 'Settings applied. Please review: $1.'], + [/^Entfernen \((\d+)\)$/, 'Remove ($1)'], + [/^Fehler (\d+)$/, 'Failed $1'], + [/^Fehler: (.+)$/, 'Error: $1'], + [/^Fertig (\d+)$/, 'Completed $1'], + [/^Fertig:$/, 'Completed:'], + [/^Gespeichert: (.+) \((.+) KB\)$/, 'Saved: $1 ($2 KB)'], + [/^Import übernommen\. Warteschlange konnte nicht vollständig gespeichert werden: (.+)$/, 'Import applied. The queue could not be saved completely: $1'], + [/^Jobs konnten nicht hinzugefuegt werden: (.+)$/, 'Jobs could not be added: $1'], + [/^Links kopieren \((\d+)\)$/, 'Copy links ($1)'], + [/^Log-Pfad nicht beschreibbar — schreibe nach: (.+)$/, 'Log path is not writable — writing to: $1'], + [/^Login: (.+)$/, 'Login: $1'], + [/^Speichern fehlgeschlagen: (.+)$/, 'Saving failed: $1'], + [/^Test erfolgreich gesendet \(HTTP (\d+)\)\.$/, 'Test sent successfully (HTTP $1).'], + [/^Übersprungen: (.+)$/, 'Skipped: $1'], + [/^Update fehlgeschlagen: (.+)$/, 'Update failed: $1'], + [/^Update wird heruntergeladen… (\d+)%$/, 'Downloading update… $1%'], + [/^Verlauf exportiert \((\d+) Zeilen\)$/, 'History exported ($1 rows)'], + [/^Verlauf gekürzt: (\d+) entfernt$/, 'History trimmed: $1 removed'], + [/^Zeige neueste (.+) von (.+) Einträgen\. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar\.$/, 'Showing the newest $1 of $2 entries. The complete history remains saved and is available through “Export history”.'], + [/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'], + [/^Zugangsdaten für (.+) bearbeiten und prüfen\.$/, 'Edit and verify credentials for $1.'], + [/^Läuft (\d+)$/, 'Running $1'], + [/^Fehler (\d+)$/, 'Failed $1'], + [/^Update v(.+) verfügbar\. Klicken zum Installieren\.$/, 'Update v$1 available. Click to install.'] + ] + : [ + [/^Update v(.+) available$/, 'Update v$1 verfügbar'], + [/^Active destination: (.+)$/, 'Aktives Ziel: $1'], + [/^Running (\d+)$/, 'Läuft $1'], + [/^Failed (\d+)$/, 'Fehler $1'], + [/^Update v(.+) available\. Click to install\.$/, 'Update v$1 verfügbar. Klicken zum Installieren.'] + ]; + for (const [pattern, replacement] of patterns) { + if (pattern.test(core)) return `${leading}${core.replace(pattern, replacement)}${trailing}`; + } + return text; + } + + function createDomLocalizer(documentRef) { + const textSources = new WeakMap(); + const textRendered = new WeakMap(); + const attributeSources = new WeakMap(); + const attributes = ['aria-label', 'placeholder', 'title', 'data-tooltip']; + let language = 'en'; + let observer = null; + + function renderTextNode(node) { + const current = node.nodeValue || ''; + if (!textSources.has(node) || current !== textRendered.get(node)) textSources.set(node, current); + const source = textSources.get(node); + const rendered = language === 'de' ? source : translateText(source, language); + textRendered.set(node, rendered); + if (current !== rendered) node.nodeValue = rendered; + } + + function renderAttributes(element) { + let sources = attributeSources.get(element); + if (!sources) { + sources = new Map(); + attributeSources.set(element, sources); + } + for (const name of attributes) { + if (!element.hasAttribute(name)) continue; + const current = element.getAttribute(name) || ''; + const known = sources.get(name); + if (!known || current !== known.rendered) sources.set(name, { source: current, rendered: current }); + const entry = sources.get(name); + const rendered = language === 'de' ? entry.source : translateText(entry.source, language); + entry.rendered = rendered; + if (current !== rendered) element.setAttribute(name, rendered); + } + } + + function renderNode(node) { + if (!node) return; + if (node.nodeType === 3) { + renderTextNode(node); + return; + } + if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return; + if (node.nodeType === 1) renderAttributes(node); + const walker = documentRef.createTreeWalker(node, 5); + let current = walker.nextNode(); + while (current) { + if (current.nodeType === 3) renderTextNode(current); + else renderAttributes(current); + current = walker.nextNode(); + } + } + + function setLanguage(value) { + language = normalizeLanguage(value); + documentRef.documentElement.lang = language; + renderNode(documentRef.body || documentRef.documentElement); + return language; + } + + function start(value = 'en') { + setLanguage(value); + if (!observer && documentRef.defaultView?.MutationObserver) { + observer = new documentRef.defaultView.MutationObserver(records => { + for (const record of records) { + if (record.type === 'characterData') renderTextNode(record.target); + else if (record.type === 'attributes') renderAttributes(record.target); + else record.addedNodes.forEach(renderNode); + } + }); + observer.observe(documentRef.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: attributes }); + } + return language; + } + + return { start, setLanguage, translate: renderNode, getLanguage: () => language }; + } + + return { normalizeLanguage, translateText, createDomLocalizer }; +}); diff --git a/renderer/index.html b/renderer/index.html index 86424bf..c673fda 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -146,14 +146,17 @@

Eine neue Version ist verfügbar

- +
- - + +
@@ -165,6 +168,7 @@

Uploads