release: Multi-Hoster-Upload 2.1.1
Add live English and German interface switching, animated navigation and history controls, improved update changelogs, remembered upload folders, reliable repeated hot reloads, and refreshed public documentation.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 45 KiB |
@@ -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: {
|
||||
|
||||
+31
-2
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+380
-48
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<nav class="settings-navigation" aria-label="Einstellungskategorien">
|
||||
<span class="settings-nav-indicator" aria-hidden="true"></span>
|
||||
${pageDefinitions.map((definition, index) => `<button class="settings-nav-button${index === 0 ? ' active' : ''}" data-settings-page="${definition.id}" data-search="${definition.label.toLowerCase()} ${definition.search}" aria-current="${index === 0 ? 'page' : 'false'}">${definition.label}</button>`).join('')}
|
||||
</nav>
|
||||
<p class="settings-search-empty" id="settingsSearchEmpty" hidden>Keine passende Einstellung gefunden.</p>
|
||||
@@ -3758,6 +3809,24 @@ function renderSettings() {
|
||||
pages.allgemein.innerHTML = `
|
||||
${pageHeader('Allgemein', 'Fensterverhalten, Drop-Target und Programmupdates.')}
|
||||
<div class="settings-section-label">Oberfläche</div>
|
||||
<div class="settings-row language-settings-row">
|
||||
<label id="languagePickerLabel">Sprache</label>
|
||||
<div class="language-picker" id="languagePicker" data-language="${globalSettings.language === 'de' ? 'de' : 'en'}" role="group" aria-labelledby="languagePickerLabel">
|
||||
<span class="language-picker-indicator" aria-hidden="true"></span>
|
||||
<button type="button" class="language-option" data-language="en" aria-pressed="${globalSettings.language !== 'de'}">
|
||||
<span class="language-flag language-flag-en" aria-hidden="true"></span>
|
||||
<span>Englisch</span>
|
||||
</button>
|
||||
<button type="button" class="language-option" data-language="de" aria-pressed="${globalSettings.language === 'de'}">
|
||||
<span class="language-flag language-flag-de" aria-hidden="true"></span>
|
||||
<span>Deutsch</span>
|
||||
</button>
|
||||
</div>
|
||||
<select class="settings-autosave" id="languageInput" hidden aria-hidden="true" tabindex="-1">
|
||||
<option value="en" ${globalSettings.language !== 'de' ? 'selected' : ''}>Englisch</option>
|
||||
<option value="de" ${globalSettings.language === 'de' ? 'selected' : ''}>Deutsch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-grid-mini">
|
||||
<div class="settings-row checkbox-row">
|
||||
<label for="alwaysOnTopInput">Immer im Vordergrund</label>
|
||||
@@ -3769,8 +3838,11 @@ function renderSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section-label">Programmupdate</div>
|
||||
<div class="settings-row">
|
||||
<label>Neue Version suchen</label>
|
||||
<div class="settings-row program-update-row program-update-card">
|
||||
<div class="program-update-copy">
|
||||
<strong class="program-update-title">Nach neuer Version suchen</strong>
|
||||
<span class="program-update-description">Verfügbare Updates werden zusammen mit dem Changelog angezeigt.</span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-secondary" id="manualUpdateCheckBtn">Nach Updates suchen</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -3898,7 +3970,7 @@ function renderSettings() {
|
||||
pages.logs.innerHTML = `
|
||||
${pageHeader('Logs & Support', 'Protokollierung verwalten, Log-Dateien öffnen und ein bereinigtes Support-Paket erstellen.')}
|
||||
<div class="settings-section-label">Log</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row log-file-path-row">
|
||||
<label>FileUploader Log</label>
|
||||
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
|
||||
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
|
||||
@@ -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 = '<p class="empty-state">Noch keine Uploads.</p>';
|
||||
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' })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
['<nicht gesetzt>', '<not set>'],
|
||||
['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 };
|
||||
});
|
||||
+46
-9
@@ -146,14 +146,17 @@
|
||||
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||
<p id="updateMessage"></p>
|
||||
</div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden></div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden>
|
||||
<div class="update-release-notes-title">Changelog</div>
|
||||
<div class="update-release-notes-body" id="updateReleaseNotesBody"></div>
|
||||
</div>
|
||||
<div class="update-progress" aria-live="polite">
|
||||
<div class="update-progress-track"><span id="updateProgressBar" role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%"></span></div>
|
||||
<span id="updateProgressText"></span>
|
||||
</div>
|
||||
<div class="update-dialog-actions">
|
||||
<button class="btn btn-secondary" id="dismissUpdateBtn">Später erinnern</button>
|
||||
<button class="btn btn-primary" id="installUpdateBtn">Jetzt updaten</button>
|
||||
<button class="btn btn-secondary" id="dismissUpdateBtn">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="installUpdateBtn">Jetzt installieren</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -165,6 +168,7 @@
|
||||
<h1 class="view-sidebar-title">Uploads</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Upload-Status">
|
||||
<span class="view-sidebar-indicator" aria-hidden="true"></span>
|
||||
<button class="view-sidebar-item active" data-upload-sidebar-target="all" aria-label="Alle Dateien anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-files"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Dateien</span>
|
||||
@@ -348,10 +352,11 @@
|
||||
<div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab">
|
||||
<aside class="view-sidebar" aria-label="Account-Übersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Zugänge</span>
|
||||
<span class="view-sidebar-kicker">Accounts verwalten</span>
|
||||
<h1 class="view-sidebar-title">Accounts</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Account-Status">
|
||||
<span class="view-sidebar-indicator" aria-hidden="true"></span>
|
||||
<button class="view-sidebar-item active" data-accounts-sidebar-filter="all" aria-label="Alle Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-accounts"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Accounts</span>
|
||||
@@ -478,15 +483,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="historyClearModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="historyClearModalTitle" aria-describedby="historyClearModalMessage" style="width:min(440px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="historyClearModalTitle">Verlauf löschen?</h3></div>
|
||||
<button class="icon-btn" id="closeHistoryClearModalBtn" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="historyClearModalMessage">Alle Verlaufseinträge werden dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancelHistoryClearBtn">Abbrechen</button>
|
||||
<button class="btn btn-danger" id="confirmHistoryClearBtn">Verlauf löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-view" class="view" role="tabpanel" aria-labelledby="settings-tab">
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<div>
|
||||
<h2>Einstellungen</h2>
|
||||
<p class="settings-hint">Alle Optionen nach Aufgaben sortiert. Änderungen werden automatisch gespeichert.</p>
|
||||
<p class="settings-hint">Alle Optionen nach Aufgaben sortiert. Änderungen werden mit dem Speichern-Button übernommen.</p>
|
||||
</div>
|
||||
<div class="settings-save-row">
|
||||
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||
<button class="btn btn-secondary" id="saveSettingsBtn" disabled>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-hosters" id="settingsHosters"></div>
|
||||
@@ -500,6 +521,7 @@
|
||||
<h1 class="view-sidebar-title">Verlauf</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Verlaufsstatus">
|
||||
<span class="view-sidebar-indicator" aria-hidden="true"></span>
|
||||
<button class="view-sidebar-item active" data-history-filter="all" aria-label="Gesamten Verlauf anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-history"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Uploads</span>
|
||||
@@ -529,8 +551,22 @@
|
||||
<div class="history-header">
|
||||
<h2>Upload-Verlauf</h2>
|
||||
<div class="history-header-actions">
|
||||
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
|
||||
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
||||
<label for="historyRetentionTrigger" class="history-retention-label">Aufbewahrung</label>
|
||||
<div class="history-retention-picker" id="historyRetentionPicker">
|
||||
<button type="button" class="btn btn-secondary history-retention-trigger" id="historyRetentionTrigger" aria-haspopup="listbox" aria-expanded="false" aria-controls="historyRetentionMenu">
|
||||
<span id="historyRetentionValue">Alles behalten</span>
|
||||
<span class="history-retention-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="history-retention-menu" id="historyRetentionMenu" role="listbox" aria-labelledby="historyRetentionTrigger" style="display:none">
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="all" aria-selected="true">Alles behalten</button>
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="7d" aria-selected="false">Letzte 7 Tage</button>
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="30d" aria-selected="false">Letzte 30 Tage</button>
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="90d" aria-selected="false">Letzte 90 Tage</button>
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="1000" aria-selected="false">Letzte 1000 Uploads</button>
|
||||
<button type="button" class="history-retention-option" role="option" data-history-retention="100" aria-selected="false">Letzte 100 Uploads</button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="historyRetentionSelect" class="history-retention-select" hidden aria-hidden="true" tabindex="-1">
|
||||
<option value="all">Alles behalten</option>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
@@ -539,7 +575,7 @@
|
||||
<option value="100">Letzte 100 Uploads</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
|
||||
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
||||
<button class="btn btn-danger" id="clearHistoryBtn" disabled>Verlauf löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
||||
@@ -633,6 +669,7 @@
|
||||
<script src="../lib/serialized-runner.js"></script>
|
||||
<script src="account-submit.js"></script>
|
||||
<script src="account-status.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+304
-10
@@ -913,7 +913,26 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
}
|
||||
#settingsSearchInput::placeholder { color: var(--text-dim); }
|
||||
#settingsSearchInput:focus { border-color: var(--accent); outline: none; }
|
||||
.settings-navigation { display: flex; flex-direction: column; gap: 3px; }
|
||||
.settings-navigation {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.settings-nav-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 0;
|
||||
border: 1px solid #444548;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-active);
|
||||
pointer-events: none;
|
||||
transition: transform .22s cubic-bezier(.2, .8, .2, 1), width .22s cubic-bezier(.2, .8, .2, 1), height .22s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
.settings-nav-button {
|
||||
min-height: 35px;
|
||||
padding: 8px 11px;
|
||||
@@ -927,12 +946,14 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: background 0.14s, color 0.14s, border-color 0.14s;
|
||||
}
|
||||
.settings-nav-button:hover { background: var(--bg-card-hover); color: var(--text); }
|
||||
.settings-nav-button.active {
|
||||
border-left-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||
border-left-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.settings-nav-button[hidden] { display: none; }
|
||||
@@ -1400,8 +1421,82 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
|
||||
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.history-header h2 { font-size: 18px; }
|
||||
.history-retention-label { font-size: 12px; color: var(--text-dim); margin-right: 2px; }
|
||||
.history-retention-select { width: auto; min-width: 150px; padding: 6px 8px; }
|
||||
.history-retention-label { font-size: 13px; line-height: 16px; color: var(--text-dim); margin-right: 2px; transform: translateY(-1px); }
|
||||
.history-retention-picker {
|
||||
position: relative;
|
||||
min-width: 150px;
|
||||
}
|
||||
.btn.history-retention-trigger {
|
||||
width: 100%;
|
||||
min-width: 150px;
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-inline: 11px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.history-retention-trigger[aria-expanded="true"] {
|
||||
border-color: var(--accent-end);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
.history-retention-chevron {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: translateY(-2px) rotate(45deg);
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.history-retention-trigger[aria-expanded="true"] .history-retention-chevron {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
.history-retention-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1200;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-hover);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, .38);
|
||||
transform-origin: top;
|
||||
}
|
||||
.history-retention-option {
|
||||
width: 100%;
|
||||
min-height: 31px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-retention-option:hover,
|
||||
.history-retention-option:focus-visible {
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.history-retention-option[aria-selected="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.history-cap-notice {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 12px;
|
||||
@@ -1605,6 +1700,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
transition-duration: 280ms !important;
|
||||
}
|
||||
|
||||
.view-sidebar-indicator,
|
||||
.settings-nav-indicator {
|
||||
transition-duration: 220ms !important;
|
||||
}
|
||||
|
||||
.language-picker-indicator {
|
||||
transition-duration: 240ms !important;
|
||||
}
|
||||
|
||||
.menu-opening {
|
||||
animation-duration: 180ms !important;
|
||||
}
|
||||
@@ -2185,6 +2289,19 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.update-release-notes-title {
|
||||
margin-bottom: 7px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.update-release-notes-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.update-progress-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
@@ -2276,6 +2393,22 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.view-sidebar-navigation {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.view-sidebar-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 0;
|
||||
border: 1px solid var(--border-hover);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-active);
|
||||
pointer-events: none;
|
||||
transition: transform .22s cubic-bezier(.2, .8, .2, 1), width .22s cubic-bezier(.2, .8, .2, 1), height .22s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
|
||||
.view-sidebar-item {
|
||||
@@ -2294,6 +2427,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: color .12s, background-color .12s, border-color .12s;
|
||||
}
|
||||
|
||||
@@ -2305,8 +2440,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
|
||||
.view-sidebar-item.active {
|
||||
color: var(--text);
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-active);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.view-sidebar-item.active .view-sidebar-icon {
|
||||
@@ -2691,6 +2826,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-dim);
|
||||
opacity: .65;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.key-input,
|
||||
.hs-input {
|
||||
min-height: 40px;
|
||||
@@ -2779,8 +2923,8 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.settings-nav-button.active {
|
||||
border-color: #444548;
|
||||
background: var(--bg-active);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -2790,10 +2934,155 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.settings-subpage {
|
||||
max-width: 560px;
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.program-update-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center !important;
|
||||
min-height: 78px !important;
|
||||
column-gap: 18px !important;
|
||||
}
|
||||
|
||||
.program-update-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.program-update-title {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.program-update-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.language-settings-row {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.language-settings-row > label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.language-picker {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.language-picker-indicator {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: calc((100% - 8px) / 2);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);
|
||||
transform: translateX(0);
|
||||
transition: transform .24s cubic-bezier(.22, 1, .36, 1);
|
||||
}
|
||||
|
||||
.language-picker[data-language="de"] .language-picker-indicator {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.language-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color .18s ease;
|
||||
}
|
||||
|
||||
.language-option[aria-pressed="true"] {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.language-option:focus-visible {
|
||||
outline: 2px solid var(--accent-end);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, .34);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.language-flag-de {
|
||||
background: linear-gradient(to bottom, #151515 0 33.33%, #d00 33.33% 66.66%, #ffce00 66.66% 100%);
|
||||
}
|
||||
|
||||
.language-flag-en {
|
||||
background: repeating-linear-gradient(to bottom, #b22234 0 8.33%, #fff 8.33% 16.66%);
|
||||
}
|
||||
|
||||
.language-flag-en::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 7px;
|
||||
background: #3c3b6e;
|
||||
}
|
||||
|
||||
.log-file-path-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 190px minmax(180px, 1fr) auto auto;
|
||||
align-items: center !important;
|
||||
column-gap: 8px !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.log-file-path-row > label {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.log-file-path-row > .key-input {
|
||||
width: 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.log-file-path-row .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-page-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
@@ -3338,6 +3627,11 @@ input[type="checkbox"] {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.history-retention-picker,
|
||||
.history-retention-trigger {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.statusbar > span:not(.sb-state):not(.sb-speed):not(.sb-error-count) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -57,12 +57,14 @@ function releaseLock() {
|
||||
}
|
||||
|
||||
function startApp() {
|
||||
child = spawn(electron, ['.', '--dev'], {
|
||||
const startedChild = spawn(electron, ['.', '--dev'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
child = startedChild;
|
||||
startedChild.once('exit', (code, signal) => {
|
||||
if (child !== startedChild) return;
|
||||
child = null;
|
||||
if (!stopping && code !== 0 && signal !== 'SIGTERM') process.exitCode = code || 1;
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ const sourceFiles = [
|
||||
'renderer/account-submit.js',
|
||||
'renderer/app.js',
|
||||
'renderer/drop-target.html',
|
||||
'renderer/i18n.js',
|
||||
'renderer/index.html',
|
||||
'renderer/styles.css',
|
||||
'scripts/afterPack.cjs',
|
||||
@@ -83,11 +84,13 @@ const sourceFiles = [
|
||||
'tests/diagnostics-agent.test.js',
|
||||
'tests/diagnostics-collectors.test.js',
|
||||
'tests/diagnostics-protocol.test.js',
|
||||
'tests/dev-runner.test.js',
|
||||
'tests/doodstream-api-upload.test.js',
|
||||
'tests/doodstream-upload.test.js',
|
||||
'tests/file-probe.test.js',
|
||||
'tests/history-retention.test.js',
|
||||
'tests/hosters.test.js',
|
||||
'tests/i18n.test.js',
|
||||
'tests/ip-allowlist.test.js',
|
||||
'tests/log-mode.test.js',
|
||||
'tests/log-policy.test.js',
|
||||
|
||||
@@ -72,11 +72,13 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.hosterSettings['doodstream.com'].retries, 3);
|
||||
assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2);
|
||||
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||
assert.equal(config.globalSettings.language, 'en');
|
||||
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
||||
assert.equal(config.globalSettings.logFilePath, '');
|
||||
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
||||
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
||||
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
||||
assert.equal(config.globalSettings.pendingQueue, null);
|
||||
assert.deepEqual(config.history, []);
|
||||
});
|
||||
@@ -458,6 +460,18 @@ describe('ConfigStore', () => {
|
||||
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
||||
});
|
||||
|
||||
it('persists the last browse directory across stale renderer settings saves', async () => {
|
||||
const selectedDirectory = path.join(tmpDir, 'selected');
|
||||
|
||||
await store.saveLastBrowseDirectory(selectedDirectory);
|
||||
await store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
lastBrowseDirectory: ''
|
||||
});
|
||||
|
||||
assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory);
|
||||
});
|
||||
|
||||
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('dev runner cannot let an old child exit clear the current Electron process', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
|
||||
|
||||
assert.match(source, /const startedChild = spawn\(electron/u);
|
||||
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { normalizeLanguage, translateText } = require('../renderer/i18n');
|
||||
|
||||
test('English is the fallback language and German remains selectable', () => {
|
||||
assert.equal(normalizeLanguage(), 'en');
|
||||
assert.equal(normalizeLanguage('fr'), 'en');
|
||||
assert.equal(normalizeLanguage('en'), 'en');
|
||||
assert.equal(normalizeLanguage('de'), 'de');
|
||||
});
|
||||
|
||||
test('translations cover static labels and interpolated status text in both languages', () => {
|
||||
assert.equal(translateText('Einstellungen', 'en'), 'Settings');
|
||||
assert.equal(translateText('Update v2.1.0 verfügbar', 'en'), 'Update v2.1.0 available');
|
||||
assert.equal(translateText('Settings', 'de'), 'Einstellungen');
|
||||
assert.equal(translateText('Update v2.1.0 available', 'de'), 'Update v2.1.0 verfügbar');
|
||||
});
|
||||
|
||||
test('sidebar hierarchy uses distinct English and German kicker labels', () => {
|
||||
assert.equal(translateText('Arbeitsbereich', 'en'), 'Workspace');
|
||||
assert.equal(translateText('Accounts verwalten', 'en'), 'Manage accounts');
|
||||
assert.equal(translateText('Archiv', 'en'), 'Archive');
|
||||
assert.equal(translateText('Workspace', 'de'), 'Arbeitsbereich');
|
||||
assert.equal(translateText('Manage accounts', 'de'), 'Accounts verwalten');
|
||||
assert.equal(translateText('Archive', 'de'), 'Archiv');
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer');
|
||||
|
||||
class TestBrowserWindow extends EventEmitter {
|
||||
@@ -40,6 +42,17 @@ test('createStartupWindow forces the main window to start hidden', () => {
|
||||
assert.equal(startup.window.options.show, false);
|
||||
});
|
||||
|
||||
test('main window uses the branded application icon', () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const createWindowStart = mainSource.indexOf('function createWindow()');
|
||||
const createWindowEnd = mainSource.indexOf('\nfunction createTray()', createWindowStart);
|
||||
const createWindowSource = mainSource.slice(createWindowStart, createWindowEnd);
|
||||
|
||||
assert.equal(fs.existsSync(path.join(projectRoot, 'assets', 'app_icon.ico')), true);
|
||||
assert.match(createWindowSource, /icon:\s*path\.join\(__dirname, ['"]assets['"], ['"]app_icon\.ico['"]\)/u);
|
||||
});
|
||||
|
||||
test('startup load registers visibility before navigation and shows only once', async () => {
|
||||
const startup = createStartupWindow(TestBrowserWindow, {});
|
||||
const loading = startup.load('renderer/index.html', () => {});
|
||||
|
||||
+174
-11
@@ -153,6 +153,42 @@ setTimeout(async () => {
|
||||
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
|
||||
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
|
||||
|
||||
const languageReady = await waitUntil(() => wc.executeJavaScript('Boolean(document.getElementById("languageInput"))'));
|
||||
check('Fresh profiles render in English by default', languageReady === true && await wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")') === 'en|en|Upload,Accounts,Settings,History');
|
||||
await wc.executeJavaScript('document.getElementById("settings-tab").click()');
|
||||
const languagePickerContract = await wc.executeJavaScript('(() => { const picker = document.getElementById("languagePicker"); const select = document.getElementById("languageInput"); const indicator = picker?.querySelector(".language-picker-indicator"); const buttons = [...(picker?.querySelectorAll(".language-option") || [])]; return [select?.hidden, buttons.length, buttons.map(button => button.dataset.language).join(","), buttons.map(button => button.getAttribute("aria-pressed")).join(","), Boolean(buttons[0]?.querySelector(".language-flag-en") && buttons[1]?.querySelector(".language-flag-de")), indicator ? parseFloat(getComputedStyle(indicator).transitionDuration) > 0 : false].join("|"); })()');
|
||||
check('Language uses a two-option animated flag picker instead of a visible dropdown', languagePickerContract === 'true|2|en,de|true,false|true|true');
|
||||
const languagePickerMotion = await wc.executeJavaScript('(async () => { const picker = document.getElementById("languagePicker"); const indicator = picker?.querySelector(".language-picker-indicator"); if (!picker || !indicator) return null; const before = indicator.getBoundingClientRect().left; picker.querySelector("[data-language=de]").click(); const germanLanguage = document.documentElement.lang; await new Promise(resolve => setTimeout(resolve, 90)); const movingRight = indicator.getBoundingClientRect().left; await new Promise(resolve => setTimeout(resolve, 170)); const german = { language: germanLanguage, selected: picker.dataset.language, pressed: picker.querySelector("[data-language=de]").getAttribute("aria-pressed"), left: indicator.getBoundingClientRect().left }; picker.querySelector("[data-language=en]").click(); const englishLanguage = document.documentElement.lang; await new Promise(resolve => setTimeout(resolve, 90)); const movingLeft = indicator.getBoundingClientRect().left; await new Promise(resolve => setTimeout(resolve, 170)); const english = { language: englishLanguage, selected: picker.dataset.language, pressed: picker.querySelector("[data-language=en]").getAttribute("aria-pressed"), left: indicator.getBoundingClientRect().left }; return { before, movingRight, movingLeft, german, english }; })()');
|
||||
if (!languagePickerMotion || !(languagePickerMotion.movingRight > languagePickerMotion.before + 2 && languagePickerMotion.movingRight < languagePickerMotion.german.left - 2) || !(languagePickerMotion.movingLeft < languagePickerMotion.german.left - 2 && languagePickerMotion.movingLeft > languagePickerMotion.before + 2)) console.log('Language picker motion: ' + JSON.stringify(languagePickerMotion));
|
||||
check('Language indicator visibly slides right and left while applying both languages immediately', Boolean(languagePickerMotion && languagePickerMotion.german.language === 'de' && languagePickerMotion.german.selected === 'de' && languagePickerMotion.german.pressed === 'true' && languagePickerMotion.movingRight > languagePickerMotion.before + 2 && languagePickerMotion.movingRight < languagePickerMotion.german.left - 2 && languagePickerMotion.english.language === 'en' && languagePickerMotion.english.selected === 'en' && languagePickerMotion.english.pressed === 'true' && languagePickerMotion.movingLeft < languagePickerMotion.german.left - 2 && languagePickerMotion.movingLeft > languagePickerMotion.before + 2 && Math.abs(languagePickerMotion.english.left - languagePickerMotion.before) <= 1));
|
||||
await captureVisual('00-language-picker.png');
|
||||
await wc.executeJavaScript('document.getElementById("upload-tab").click()');
|
||||
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
|
||||
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI-HOSTER UPLOAD', 'Markdown', 'Multi-Hoster Upload', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'mp4,mkv,avi']);
|
||||
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !value.includes('Multi-Hoster-Uploader'));
|
||||
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
|
||||
check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0);
|
||||
const englishValues = await wc.executeJavaScript('(() => { const values = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const value = node.nodeValue.trim(); if (value) values.push(value); node = walker.nextNode(); } values.push(...[...document.querySelectorAll("[title],[aria-label],[placeholder]")].flatMap(element => [element.title, element.getAttribute("aria-label"), element.getAttribute("placeholder")])); return [...new Set(values.filter(Boolean))]; })()');
|
||||
const germanTerms = ['ä', 'ö', 'ü', 'ß', 'Allgemein', 'Änderungen', 'Abbrechen', 'Aktiv', 'Alle', 'Anzeigen', 'Accounts hinzufügen', 'Arbeitsbereich', 'Archiv', 'Auswahl', 'Auswählen', 'Automatik', 'Bearbeiten', 'Benachrichtigungen', 'Bereit', 'Datei', 'Dateien', 'Deaktiviert', 'Diagnose', 'Einstellungen', 'Englisch', 'Entfernen', 'Erfolgreich', 'Erstellt', 'Fehler', 'Fernsteuerung', 'Fortschritt', 'Geschwindigkeit', 'Gestern', 'Gestoppt', 'Hilfe', 'Hinzufügen', 'Inaktiv', 'Keine', 'Konnte', 'Kopieren', 'Löschen', 'Nach', 'Neue', 'Nicht', 'Öffnen', 'Ordner', 'Primär', 'Priorität', 'Prüfen', 'Schließen', 'Sekunden', 'Speichern', 'Sprache', 'Stunden', 'Unbekannt', 'Verlauf', 'verwendet', 'Warteschlange', 'Wird', 'Zeigen', 'Ziel'];
|
||||
const containsGermanTerm = value => { const lower = value.toLocaleLowerCase('de-DE'); const words = lower.match(/[A-Za-zÄÖÜäöüß]+/g) || []; return germanTerms.some(term => term.length === 1 ? value.includes(term) : term.includes(' ') ? lower.includes(term.toLocaleLowerCase('de-DE')) : words.includes(term.toLocaleLowerCase('de-DE'))); };
|
||||
const englishResidue = englishValues.filter(containsGermanTerm);
|
||||
if (englishResidue.length) console.log('English residue: ' + JSON.stringify(englishResidue, null, 2));
|
||||
check('English default leaves no German interface copy behind', englishResidue.length === 0);
|
||||
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
|
||||
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
|
||||
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
|
||||
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
|
||||
const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()');
|
||||
check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de');
|
||||
const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
|
||||
check('German sidebar hierarchy uses distinct localized kickers', germanSidebarHeadings.join('::') === 'Arbeitsbereich|Uploads::Accounts verwalten|Accounts::Archiv|Verlauf');
|
||||
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-success")].join("|"); })()');
|
||||
check('Changing language enables the green save action', saveAfterLanguageChange === 'false|true');
|
||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled'));
|
||||
const saveAfterCommit = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-secondary")].join("|"); })()');
|
||||
check('Saving returns the action to its disabled gray state', saveAfterCommit === 'true|true');
|
||||
|
||||
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
|
||||
console.log('\\n=== Upload View ===');
|
||||
|
||||
@@ -257,8 +293,24 @@ setTimeout(async () => {
|
||||
const uploadSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#upload-view .view-sidebar-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && document.getElementById("uploadSidebarAccountsCount")); })()');
|
||||
check('Upload sidebar keeps availability information in its lower area', uploadSidebarInformation === true);
|
||||
|
||||
const uploadSidebarBorders = await wc.executeJavaScript('(() => { const items = [...document.querySelectorAll("#upload-view .view-sidebar-item")]; const visible = items.every(item => { const style = getComputedStyle(item); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && !style.borderTopColor.endsWith(", 0)"); }); const active = items.find(item => item.classList.contains("active")); const inactive = items.find(item => !item.classList.contains("active")); return [items.length, visible, active && inactive && getComputedStyle(active).borderTopColor !== getComputedStyle(inactive).borderTopColor].join("|"); })()');
|
||||
check('Upload sidebar filters use visible borders with a stronger active state', uploadSidebarBorders === '5|true|true');
|
||||
const uploadSidebarBorders = await wc.executeJavaScript('(() => { const items = [...document.querySelectorAll("#upload-view .view-sidebar-item")]; const inactive = items.filter(item => !item.classList.contains("active")); const inactiveBorders = inactive.every(item => { const style = getComputedStyle(item); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && !style.borderTopColor.endsWith(", 0)"); }); const active = items.find(item => item.classList.contains("active")); const indicator = document.querySelector("#upload-view .view-sidebar-indicator"); const activeTransparent = active && getComputedStyle(active).backgroundColor === "rgba(0, 0, 0, 0)"; const indicatorVisible = indicator && getComputedStyle(indicator).borderTopWidth === "1px" && !getComputedStyle(indicator).borderTopColor.endsWith(", 0)"); return [items.length, inactiveBorders, activeTransparent, indicatorVisible].join("|"); })()');
|
||||
check('Upload sidebar filters keep individual borders and move the active surface to the indicator', uploadSidebarBorders === '5|true|true|true');
|
||||
|
||||
const sidebarIndicatorCount = await wc.executeJavaScript('document.querySelectorAll(".view-sidebar-navigation > .view-sidebar-indicator").length');
|
||||
check('Upload, account and history sidebars expose moving selection indicators', sidebarIndicatorCount === 3);
|
||||
|
||||
await wc.executeJavaScript('window.__uiUploadIndicatorStart = document.querySelector("#upload-view .view-sidebar-indicator")?.getBoundingClientRect().top; document.querySelector("[data-upload-sidebar-target=error]")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 90));
|
||||
const uploadIndicatorMovingDown = await wc.executeJavaScript('(() => { const indicator = document.querySelector("#upload-view .view-sidebar-indicator"); const target = document.querySelector("[data-upload-sidebar-target=error]"); const start = window.__uiUploadIndicatorStart; if (!indicator || !target || !Number.isFinite(start)) return "missing"; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; const duration = parseFloat(getComputedStyle(indicator).transitionDuration); return [current > start + 2 && current < targetTop - 2, duration >= .15].join("|"); })()');
|
||||
check('Upload sidebar indicator remains visibly in motion while gliding down', uploadIndicatorMovingDown === 'true|true');
|
||||
await new Promise(resolve => setTimeout(resolve, 170));
|
||||
const uploadIndicatorAtError = await wc.executeJavaScript('(() => { const indicator = document.querySelector("#upload-view .view-sidebar-indicator"); const target = document.querySelector("[data-upload-sidebar-target=error]"); if (!indicator || !target) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); window.__uiUploadIndicatorErrorTop = indicatorRect.top; return [Math.abs(indicatorRect.top - targetRect.top) <= 1, Math.abs(indicatorRect.height - targetRect.height) <= 1].join("|"); })()');
|
||||
check('Upload sidebar indicator glides to a lower filter', uploadIndicatorAtError === 'true|true');
|
||||
await wc.executeJavaScript('document.querySelector("[data-upload-sidebar-target=all]")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 90));
|
||||
const uploadIndicatorMovingUp = await wc.executeJavaScript('(() => { const indicator = document.querySelector("#upload-view .view-sidebar-indicator"); const target = document.querySelector("[data-upload-sidebar-target=all]"); const start = window.__uiUploadIndicatorErrorTop; if (!indicator || !target || !Number.isFinite(start)) return false; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; return current < start - 2 && current > targetTop + 2; })()');
|
||||
check('Upload sidebar indicator remains visibly in motion while gliding up', uploadIndicatorMovingUp === true);
|
||||
await new Promise(resolve => setTimeout(resolve, 170));
|
||||
|
||||
const uploadFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
||||
check('Upload view and statusbar fit inside the viewport', uploadFrameFit === true);
|
||||
@@ -577,9 +629,34 @@ setTimeout(async () => {
|
||||
|
||||
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
|
||||
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');
|
||||
|
||||
const settingsIndicatorContract = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-navigation > .settings-nav-indicator"); const active = document.querySelector(".settings-nav-button.active"); if (!indicator || !active) return "missing"; const indicatorStyle = getComputedStyle(indicator); const activeStyle = getComputedStyle(active); const indicatorRect = indicator.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); return [activeStyle.backgroundColor === "rgba(0, 0, 0, 0)", indicatorStyle.borderTopWidth === "1px", parseFloat(indicatorStyle.transitionDuration) >= .15, Math.abs(indicatorRect.top - activeRect.top) <= 1, Math.abs(indicatorRect.height - activeRect.height) <= 1].join("|"); })()');
|
||||
check('Settings navigation moves its active surface onto one sliding indicator', settingsIndicatorContract === 'true|true|true|true|true');
|
||||
|
||||
await wc.executeJavaScript('window.__uiSettingsIndicatorStart = document.querySelector(".settings-nav-indicator")?.getBoundingClientRect().top; document.querySelector("[data-settings-page=backup]")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 90));
|
||||
const settingsIndicatorMovingDown = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-nav-indicator"); const target = document.querySelector("[data-settings-page=backup]"); const start = window.__uiSettingsIndicatorStart; if (!indicator || !target || !Number.isFinite(start)) return false; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; return current > start + 2 && current < targetTop - 2; })()');
|
||||
check('Settings indicator remains visibly in motion while gliding down', settingsIndicatorMovingDown === true);
|
||||
await new Promise(resolve => setTimeout(resolve, 170));
|
||||
const settingsIndicatorAtBackup = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-nav-indicator"); const target = document.querySelector("[data-settings-page=backup]"); if (!indicator || !target) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); window.__uiSettingsIndicatorBackupTop = indicatorRect.top; return [Math.abs(indicatorRect.top - targetRect.top) <= 1, Math.abs(indicatorRect.height - targetRect.height) <= 1].join("|"); })()');
|
||||
check('Settings indicator glides to a lower category', settingsIndicatorAtBackup === 'true|true');
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 90));
|
||||
const settingsIndicatorMovingUp = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-nav-indicator"); const target = document.querySelector("[data-settings-page=allgemein]"); const start = window.__uiSettingsIndicatorBackupTop; if (!indicator || !target || !Number.isFinite(start)) return false; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; return current < start - 2 && current > targetTop + 2; })()');
|
||||
check('Settings indicator remains visibly in motion while gliding up', settingsIndicatorMovingUp === true);
|
||||
await new Promise(resolve => setTimeout(resolve, 170));
|
||||
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()');
|
||||
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
|
||||
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
|
||||
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 12, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
|
||||
check('Program update action sits at the lower right of its card', updateActionAlignment === 'true|true|true');
|
||||
const updateCardContract = await wc.executeJavaScript('(() => { const card = document.querySelector(".program-update-card"); const title = card?.querySelector(".program-update-title"); const description = card?.querySelector(".program-update-description"); const button = document.getElementById("manualUpdateCheckBtn"); if (!card || !title || !description || !button) return "missing"; const cardRect = card.getBoundingClientRect(); const titleRect = title.getBoundingClientRect(); const descriptionRect = description.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); const center = rect => rect.top + rect.height / 2; return [title.textContent.trim(), description.textContent.trim(), titleRect.top < descriptionRect.top, Math.abs(center(buttonRect) - center(cardRect)) <= 2, buttonRect.right <= cardRect.right - 10, titleRect.left >= cardRect.left + 10].join("|"); })()');
|
||||
check('Program update card uses a clear title, description and vertically centered action', updateCardContract === 'Nach neuer Version suchen|Verfügbare Updates werden zusammen mit dem Changelog angezeigt.|true|true|true|true');
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=logs]")?.click()');
|
||||
const logPathAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".log-file-path-row")?.getBoundingClientRect(); const input = document.getElementById("logFilePathInput")?.getBoundingClientRect(); const choose = document.getElementById("chooseLogFilePathBtn")?.getBoundingClientRect(); const open = document.getElementById("openLogFolderBtn")?.getBoundingClientRect(); if (!row || !input || !choose || !open) return "missing"; const center = rect => rect.top + rect.height / 2; return [Math.abs(center(input) - center(choose)) <= 1, Math.abs(center(choose) - center(open)) <= 1, open.left > choose.right, open.right <= row.right + 1].join("|"); })()');
|
||||
check('FileUploader Log actions stay in one row with Open on the right', logPathAlignment === 'true|true|true|true');
|
||||
|
||||
const settingsSidebarInformation = await wc.executeJavaScript('(() => { const feedback = document.getElementById("saveFeedback"); const sidebar = document.querySelector(".settings-sidebar"); const status = document.querySelector(".settings-sidebar-status"); return Boolean(feedback && sidebar?.contains(feedback) && status && !document.querySelector(".settings-header #saveFeedback")); })()');
|
||||
check('Settings sidebar owns the persistent save information', settingsSidebarInformation === true);
|
||||
@@ -610,7 +687,7 @@ setTimeout(async () => {
|
||||
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
|
||||
|
||||
const settingsReadingWidth = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); if (!activePage) return 0; return activePage.getBoundingClientRect().width; })()');
|
||||
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 640);
|
||||
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 760);
|
||||
|
||||
const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
||||
check('Settings view and statusbar fit inside the viewport', settingsFrameFit === true);
|
||||
@@ -658,7 +735,7 @@ setTimeout(async () => {
|
||||
check('Online restore menu opens the backup page and focuses the key', onlineRestoreNavigation === 'onlineBackupKeyInput|backup');
|
||||
|
||||
// Test save
|
||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||
await wc.executeJavaScript('document.getElementById("alwaysOnTopInput").click(); document.getElementById("saveSettingsBtn").click()');
|
||||
let feedback = '';
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent');
|
||||
@@ -669,6 +746,23 @@ setTimeout(async () => {
|
||||
|
||||
const originalShowSaveDialog = dialog.showSaveDialog;
|
||||
const originalShowOpenDialog = dialog.showOpenDialog;
|
||||
const selectedBrowseDirectory = path.join(app.getPath('userData'), 'selected-upload-directory');
|
||||
const selectedBrowseFile = path.join(selectedBrowseDirectory, 'video.mp4');
|
||||
fs.mkdirSync(selectedBrowseDirectory, { recursive: true });
|
||||
fs.writeFileSync(selectedBrowseFile, 'video', 'utf-8');
|
||||
const browseDialogStarts = [];
|
||||
dialog.showOpenDialog = async (_window, options) => {
|
||||
browseDialogStarts.push(options.defaultPath);
|
||||
return browseDialogStarts.length === 1
|
||||
? { canceled: false, filePaths: [selectedBrowseFile] }
|
||||
: { canceled: true, filePaths: [] };
|
||||
};
|
||||
const selectedBrowseFiles = await wc.executeJavaScript('window.api.selectFiles()');
|
||||
const canceledBrowseFiles = await wc.executeJavaScript('window.api.selectFiles()');
|
||||
const persistedBrowseDirectory = (await wc.executeJavaScript('window.api.getConfig()')).globalSettings.lastBrowseDirectory;
|
||||
check('File picker starts in Downloads and reopens in the last selected directory', browseDialogStarts[0] === app.getPath('downloads') && browseDialogStarts[1] === selectedBrowseDirectory && selectedBrowseFiles?.[0] === selectedBrowseFile && canceledBrowseFiles === null && persistedBrowseDirectory === selectedBrowseDirectory);
|
||||
dialog.showOpenDialog = originalShowOpenDialog;
|
||||
try { fs.rmSync(selectedBrowseDirectory, { recursive: true, force: true }); } catch {}
|
||||
const exportRacePath = path.join(app.getPath('userData'), 'ui-export-race.json');
|
||||
dialog.showSaveDialog = async () => ({ canceled: false, filePath: exportRacePath });
|
||||
const exportRaceConfig = await wc.executeJavaScript('window.api.getConfig()');
|
||||
@@ -1005,15 +1099,23 @@ setTimeout(async () => {
|
||||
|
||||
console.log('\\n=== History View ===');
|
||||
|
||||
ipcMain.removeHandler('get-history');
|
||||
ipcMain.handle('get-history', () => [{
|
||||
let historyFixture = [{
|
||||
timestamp: '2026-08-10T10:00:00.000Z',
|
||||
files: [
|
||||
{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] },
|
||||
{ name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] },
|
||||
{ name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }
|
||||
]
|
||||
}]);
|
||||
}];
|
||||
ipcMain.removeHandler('get-history');
|
||||
ipcMain.handle('get-history', () => historyFixture);
|
||||
let clearHistoryCallCount = 0;
|
||||
ipcMain.removeHandler('clear-history');
|
||||
ipcMain.handle('clear-history', () => {
|
||||
clearHistoryCallCount++;
|
||||
historyFixture = [];
|
||||
return true;
|
||||
});
|
||||
|
||||
await wc.executeJavaScript('_historyDirty = true; document.querySelector(".tab[data-view=\\'history\\']").click()');
|
||||
await new Promise(r => setTimeout(r, 1000)); // Wait for async loadHistory
|
||||
@@ -1027,6 +1129,53 @@ setTimeout(async () => {
|
||||
const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()');
|
||||
check('History sidebar shows the active retention in its lower area', historySidebarInformation === true);
|
||||
|
||||
const historyRetentionPickerContract = await wc.executeJavaScript('(() => { const label = document.querySelector(".history-retention-label"); const value = document.getElementById("historyRetentionValue"); const select = document.getElementById("historyRetentionSelect"); const trigger = document.getElementById("historyRetentionTrigger"); const menu = document.getElementById("historyRetentionMenu"); const exportButton = document.getElementById("exportHistoryBtn"); if (!label || !value || !select || !trigger || !menu || !exportButton) return "missing"; const textRect = element => { const range = document.createRange(); range.selectNodeContents(element); return range.getBoundingClientRect(); }; const labelTextRect = textRect(label); const valueTextRect = textRect(value); const triggerRect = trigger.getBoundingClientRect(); const exportRect = exportButton.getBoundingClientRect(); const textAligned = Math.abs(labelTextRect.bottom - valueTextRect.bottom) <= .5; return [select.hidden, trigger.textContent.trim(), menu.querySelectorAll("[role=option]").length, trigger.getAttribute("aria-haspopup"), trigger.getAttribute("aria-expanded"), parseFloat(getComputedStyle(label).fontSize), parseFloat(getComputedStyle(trigger).fontSize), textAligned, Math.abs(triggerRect.height - exportRect.height) <= 1].join("|"); })()');
|
||||
check('History retention uses a compact accessible picker with aligned text and export action', historyRetentionPickerContract === 'true|Alles behalten|6|listbox|false|13|13|true|true');
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("historyRetentionTrigger")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
const historyRetentionOpening = await wc.executeJavaScript('(() => { const menu = document.getElementById("historyRetentionMenu"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-opening"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), parseFloat(style.animationDuration) >= .12, document.getElementById("historyRetentionTrigger")?.getAttribute("aria-expanded")].join("|"); })()');
|
||||
check('History retention menu visibly unfolds from top to bottom', historyRetentionOpening === 'true|true|true|true|true');
|
||||
await new Promise(resolve => setTimeout(resolve, 160));
|
||||
await captureVisual('04-history-retention-open.png');
|
||||
await wc.executeJavaScript('document.getElementById("historyRetentionTrigger")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
const historyRetentionClosing = await wc.executeJavaScript('(() => { const menu = document.getElementById("historyRetentionMenu"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-closing"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), document.getElementById("historyRetentionTrigger")?.getAttribute("aria-expanded")].join("|"); })()');
|
||||
check('History retention menu remains visible while folding from bottom to top', historyRetentionClosing === 'true|true|true|false');
|
||||
await new Promise(resolve => setTimeout(resolve, 160));
|
||||
const historyRetentionClosed = await wc.executeJavaScript('document.getElementById("historyRetentionMenu") ? getComputedStyle(document.getElementById("historyRetentionMenu")).display : "missing"');
|
||||
check('History retention menu is hidden after its closing motion', historyRetentionClosed === 'none');
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("historyRetentionTrigger")?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
const historyRetentionKeyboardOpen = await wc.executeJavaScript('(() => { const trigger = document.getElementById("historyRetentionTrigger"); const selected = document.querySelector("#historyRetentionMenu [aria-selected=true]"); return [trigger?.getAttribute("aria-expanded"), document.activeElement === selected].join("|"); })()');
|
||||
check('History retention picker opens from the keyboard and focuses the selected option', historyRetentionKeyboardOpen === 'true|true');
|
||||
await wc.executeJavaScript('document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
const historyRetentionKeyboardClosed = await wc.executeJavaScript('(() => { const trigger = document.getElementById("historyRetentionTrigger"); const menu = document.getElementById("historyRetentionMenu"); return [trigger?.getAttribute("aria-expanded"), getComputedStyle(menu).display, document.activeElement === trigger].join("|"); })()');
|
||||
check('Escape closes the history retention picker and restores trigger focus', historyRetentionKeyboardClosed === 'false|none|true');
|
||||
|
||||
await wc.executeJavaScript('document.getElementById("historyRetentionTrigger")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))');
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
const historyRetentionOutsideClosing = await wc.executeJavaScript('document.getElementById("historyRetentionMenu")?.classList.contains("menu-closing")');
|
||||
check('Clicking outside starts the history retention closing motion', historyRetentionOutsideClosing === true);
|
||||
await new Promise(resolve => setTimeout(resolve, 160));
|
||||
|
||||
const historyClearAction = await wc.executeJavaScript('(() => { const button = document.getElementById("clearHistoryBtn"); window.__historyOriginalConfirm = window.confirm; window.__historyNativeConfirmCalls = 0; window.confirm = () => { window.__historyNativeConfirmCalls++; return false; }; button?.click(); const modal = document.getElementById("historyClearModal"); return [button?.classList.contains("btn-danger"), button?.disabled, window.__historyNativeConfirmCalls, modal?.style.display, modal?.getAttribute("aria-hidden"), document.getElementById("historyClearModalTitle")?.textContent?.trim(), document.activeElement?.id].join("|"); })()');
|
||||
check('History clear uses a red enabled action and opens the styled confirmation dialog', historyClearAction === 'true|false|0|flex|false|Verlauf löschen?|confirmHistoryClearBtn');
|
||||
const historyClearMessage = await wc.executeJavaScript('document.getElementById("historyClearModalMessage")?.textContent?.trim()');
|
||||
check('History clear dialog explains that deletion is permanent', historyClearMessage === 'Alle Verlaufseinträge werden dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.');
|
||||
await captureVisual('04-history-clear-modal.png');
|
||||
await wc.executeJavaScript('document.getElementById("confirmHistoryClearBtn")?.click(); true');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("clearHistoryBtn")?.disabled'));
|
||||
const clearedHistoryState = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const button = document.getElementById("clearHistoryBtn"); button?.click(); return [modal?.style.display, modal?.getAttribute("aria-hidden"), button?.disabled, document.querySelector("#historyContainer .empty-state")?.textContent?.trim()].join("|"); })()');
|
||||
check('Clearing history closes the dialog and disables the action for the empty state', clearHistoryCallCount === 1 && clearedHistoryState === 'none|true|true|Noch keine Uploads.');
|
||||
await captureVisual('04-history-empty.png');
|
||||
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }] }];
|
||||
await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })');
|
||||
|
||||
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
|
||||
check('History view and statusbar fit inside the viewport', historyFrameFit === true);
|
||||
|
||||
@@ -1097,6 +1246,13 @@ setTimeout(async () => {
|
||||
})()\`);
|
||||
check('Failed history rows keep readable text contrast (' + historyErrorContrast.toFixed(2) + ':1)', historyErrorContrast >= 4.5);
|
||||
|
||||
await wc.executeJavaScript('setUiLanguage("en")');
|
||||
const dynamicEnglishValues = await wc.executeJavaScript('(() => { const values = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const value = node.nodeValue.trim(); if (value) values.push(value); node = walker.nextNode(); } values.push(...[...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => [element.title, element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("data-tooltip")])); return [...new Set(values.filter(Boolean))]; })()');
|
||||
const dynamicEnglishResidue = dynamicEnglishValues.filter(containsGermanTerm);
|
||||
if (dynamicEnglishResidue.length) console.log('Dynamic English residue: ' + JSON.stringify(dynamicEnglishResidue, null, 2));
|
||||
check('English translation covers dynamically rendered interface states', dynamicEnglishResidue.length === 0);
|
||||
await wc.executeJavaScript('setUiLanguage("de")');
|
||||
|
||||
console.log('\\n=== Global UI ===');
|
||||
|
||||
const shutdownHidden = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display');
|
||||
@@ -1128,11 +1284,14 @@ setTimeout(async () => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
const cellRect = cell?.getBoundingClientRect();
|
||||
const sidebarIndicatorRect = document.querySelector('#upload-view .view-sidebar-indicator')?.getBoundingClientRect();
|
||||
const activeSidebarRect = document.querySelector('#upload-view .view-sidebar-item.active')?.getBoundingClientRect();
|
||||
return {
|
||||
headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1),
|
||||
cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1),
|
||||
headerHeight: headerRect?.height,
|
||||
rowHeight: row?.getBoundingClientRect().height
|
||||
rowHeight: row?.getBoundingClientRect().height,
|
||||
sidebarIndicatorAligned: Boolean(sidebarIndicatorRect && activeSidebarRect && Math.abs(sidebarIndicatorRect.top - activeSidebarRect.top) <= 1 && Math.abs(sidebarIndicatorRect.width - activeSidebarRect.width) <= 1 && Math.abs(sidebarIndicatorRect.height - activeSidebarRect.height) <= 1)
|
||||
};
|
||||
})()\`);
|
||||
}
|
||||
@@ -1144,9 +1303,11 @@ setTimeout(async () => {
|
||||
check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible);
|
||||
check('Responsive queue keeps a compact table header', queueProgressVisibility.standard.headerHeight <= 34 && queueProgressVisibility.minimum.headerHeight <= 34);
|
||||
check('Responsive queue keeps the fixed virtual row height', queueProgressVisibility.standard.rowHeight === 28 && queueProgressVisibility.minimum.rowHeight === 28);
|
||||
check('Sidebar indicator stays aligned at the standard window size', queueProgressVisibility.standard.sidebarIndicatorAligned);
|
||||
check('Sidebar indicator stays aligned at the minimum window size', queueProgressVisibility.minimum.sidebarIndicatorAligned);
|
||||
check('Minimum window keeps the settings header compact', compactSettingsHeader <= 58);
|
||||
|
||||
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "Added live language switching.\\\\nImproved settings layout." }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
@@ -1172,7 +1333,9 @@ setTimeout(async () => {
|
||||
check('Update dialog names the available version', updateDialogCopy === 'Eine neue Version ist verfügbar|Update v9.9.9 verfügbar');
|
||||
|
||||
const updateDialogActions = await wc.executeJavaScript('[document.getElementById("dismissUpdateBtn")?.textContent?.trim(), document.getElementById("installUpdateBtn")?.textContent?.trim()].join("|")');
|
||||
check('Update dialog offers later and install actions', updateDialogActions === 'Später erinnern|Jetzt updaten');
|
||||
check('Update dialog offers cancel and install actions', updateDialogActions === 'Abbrechen|Jetzt installieren');
|
||||
const updateDialogChangelog = await wc.executeJavaScript('document.getElementById("updateReleaseNotes")?.hidden + "|" + document.querySelector(".update-release-notes-title")?.textContent?.trim() + "|" + document.getElementById("updateReleaseNotesBody")?.textContent');
|
||||
check('Update dialog shows the GitHub changelog with real line breaks', updateDialogChangelog === 'false|Changelog|Added live language switching.\\nImproved settings layout.');
|
||||
|
||||
const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()');
|
||||
check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.');
|
||||
@@ -1319,7 +1482,7 @@ setTimeout(async () => {
|
||||
releaseBlockedHistoryWrite = null;
|
||||
const pendingCloseHistoryWrite = activeConfigStore.appendHistory({ id: 'ui-close-history-write', files: [] });
|
||||
await waitUntil(() => blockedHistoryWriteStarted);
|
||||
await wc.executeJavaScript('(() => { config.globalSettings = { ...(config.globalSettings || {}), webhookUrl: "' + closeSnapshotWebhook + '" }; const webhookInput = document.getElementById("webhookUrlInput"); if (webhookInput) webhookInput.value = "' + closeSnapshotWebhook + '"; selectedFiles = []; queueJobs = [{ id: "' + closeSnapshotJobId + '", file: "C:/ui/close-persist.bin", fileName: "close-persist.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 4096, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); scheduleSettingsSave(); persistQueueStateSoon(false); return true; })()');
|
||||
await wc.executeJavaScript('(() => { config.globalSettings = { ...(config.globalSettings || {}), webhookUrl: "' + closeSnapshotWebhook + '" }; const webhookInput = document.getElementById("webhookUrlInput"); if (webhookInput) webhookInput.value = "' + closeSnapshotWebhook + '"; selectedFiles = []; queueJobs = [{ id: "' + closeSnapshotJobId + '", file: "C:/ui/close-persist.bin", fileName: "close-persist.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 4096, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); markSettingsDirty(); persistQueueStateSoon(false); return saveSettings({ feedbackText: "Gespeichert!" }); })()');
|
||||
let mainWindowClosed = false;
|
||||
win.once('closed', () => { mainWindowClosed = true; });
|
||||
await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate()');
|
||||
|
||||
@@ -5,7 +5,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const { isNewer, resolveReleaseVersion, prepareUpdate, launchPreparedUpdate } = require('../lib/updater');
|
||||
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate } = require('../lib/updater');
|
||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||
|
||||
test('bridge title resolves product version instead of transport tag', () => {
|
||||
@@ -22,6 +22,23 @@ test('release arguments reject a malformed transport tag', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('matching GitHub release notes replace the private release body', async () => {
|
||||
const calls = [];
|
||||
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return { ok: true, json: async () => ({ tag_name: 'v2.1.0', body: '## Public changes\n\n- English UI' }) };
|
||||
});
|
||||
|
||||
assert.equal(notes, '## Public changes\n\n- English UI');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, 'https://api.github.com/repos/Sucukdeluxe/Multi-Hoster-Upload/releases/tags/v2.1.0');
|
||||
});
|
||||
|
||||
test('GitHub release-note failures preserve the private release body', async () => {
|
||||
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async () => ({ ok: false }));
|
||||
assert.equal(notes, 'Private fallback');
|
||||
});
|
||||
|
||||
test('update preparation writes a verified installer without launching it', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
||||
const installer = Buffer.alloc(128 * 1024, 0);
|
||||
|
||||
Reference in New Issue
Block a user