diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c92542..463a085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [Unreleased] +## [2.0.74] - 2026-08-26 + +### Package-based link collector + +- Restore the package-based collector with expandable packages, file metadata, exact sizes, hoster identity, progressive availability results, search, filtering, selection, removal, and explicit transfer to Downloads. +- Keep DLC files dropped on the Downloads tab on the direct import path while reserving collector analysis for imports made inside the collector. +- Persist packages, metadata, and collapsed state across restarts with primary/backup recovery, a synchronous shutdown handoff, and deterministic merging when the window closes before initial hydration finishes. +- Collapse newly imported packages by default while preserving manual disclosure state across duplicate imports, metadata regrouping, and restarts. +- Add visible select-all, mixed checkbox state, Ctrl+A, Escape, offline-link removal, queue-export JSON round trips, and complete German and English collector text. +- Validate cumulative package, link, field-length, and 64 MiB persistence limits before mutating the interface, including metadata enrichment and late hydration. +- Roll back the visible collector state after persistence failures, prune stale selection and disclosure state, and prevent rejected saves from being reported as successful. +- Virtualize large package lists and disclosure transitions, cache stable regions, and reduce 20,000-link persistence updates to link-level deltas without rebuilding or serializing the complete collector. + +### Download queue and controls + +- Keep hidden extracted entries out of sidebar and queue totals without changing their package download size, completion count, or progress aggregates. +- Distinguish a genuinely empty queue, a hidden-only queue, and an empty filter result, with matching guidance and correctly disabled disclosure actions. +- Calculate remaining volume and ETA from the current run only, excluding unselected or disabled queue items while retaining explicit unknown-size handling. +- Normalize stale provider filters, package lifecycle states, mixed extraction results, and locale-formatted sidebar counters. +- Make toolbar and context-menu starts obey the same account, cooldown, busy, and action gates and report blocked or rejected starts instead of silently doing nothing. +- Serialize pause and resume actions so rapid clicks produce one state transition and the renderer changes only after the main process confirms the result. +- Add monotonic snapshot revisions so delayed initial snapshots, retries, full updates, and deltas cannot restore deleted rows or stale account and quota gates. +- Split the header speed graph and numeric value into stable-width modules so changing one-, two-, or three-digit speeds no longer shifts the graph. + +### History and statistics + +- Paginate and lazily project history rows so a 100,000-entry history no longer rebuilds and formats the complete dataset every second. +- Preserve selections across history pages, limit select-all and deselect-all to the current page, and keep context-menu actions from discarding selections on other pages. +- Correct Last 7 Days to include all available data from today and the previous six calendar days without requiring a complete seven-day history. +- Report the actual visible page count and clear stale rows, selection, expansion, and context state after a failed history load before performing a complete retry. +- Recover readable history backups without failing startup, replace imported history atomically, and roll retention changes back if settings persistence fails. +- Persist the start of rolling minute coverage so partial 24-hour datasets are described accurately rather than shown as complete. +- Keep statistics resets consistent across ledger and settings writes, rebase manual provider-usage resets, and recover post-reset provider bytes without double counting. +- Mark byte-only recovery days explicitly so unavailable file, error, speed, minute, and account details are not displayed as misleading zero values. + +### Settings, themes, and language + +- Store Light, Dark, and System as an authoritative theme preference in settings and backups while resolving System against the current Windows color scheme. +- Prevent delayed initial loading from exposing editable default settings or empty statistics and add a visible retry state for snapshot failures. +- Capture settings save intent before queued account operations so later edits remain unsaved instead of being overwritten by an older completed save. +- Complete German and English localization for collector, download, history, statistics, theme, count, unit, duration, and mixed-status text. +- Define the shared panel surface token for Dark, Light, and System themes and align standalone and shell sidebars consistently. + +### Persistence and recovery + +- Make full and settings-only backup imports transactional across settings, session, statistics, history, log storage, and remote diagnostics before changing runtime state. +- Wait for remote diagnostics restart and restore the previous files, runtime settings, log configuration, and server state when restart fails. +- Add a persistence barrier for imports and make asynchronous settings and session commits perform their final generation check and atomic rename without yielding, preventing older writes from overwriting sync saves, updates, or shutdown state. +- Preserve the current live session during settings-only imports before releasing blocked persistence work. +- Retry transient Windows rename failures in the Discord notification outbox for synchronous and asynchronous writes without preventing application startup. + ## [2.0.73] - 2026-08-25 ### Settings workflow diff --git a/package-lock.json b/package-lock.json index cf19e90..5d44e6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.73", + "version": "2.0.74", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.73", + "version": "2.0.74", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 7e6e1a5..8b20a3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.73", + "version": "2.0.74", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 96052e7..9861016 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -59,7 +59,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { acquirePersistenceBarrier, addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createFileRollback, createHistoryRollback, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, replaceHistory, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { runInstallWithResume } from "./update-install-flow"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; @@ -169,7 +169,7 @@ export class AppController { ); } this.initializeLogStorage(); - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + this.runHistoryLifecycleCleanup("Start", () => resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)); const loadResult = loadSessionWithStatus(this.storagePaths); const session = loadResult.session; this.notificationOutbox = new NotificationOutbox({ @@ -465,22 +465,41 @@ export class AppController { return this.getRemoteDiagnostics(); } - private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void { + private persistRemoteDiagnosticsFromBackup(section: unknown): ReturnType { const restore = resolveRemoteDiagnosticsRestore(section); - if (!restore) { - return; - } - writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist }); - if (restartNow) { - void restartDebugServer().catch(() => {}); - } - this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", { - port: restore.port ?? null, - allowlistCount: restore.allowlist?.length ?? 0, + if (!restore) { + return null; + } + writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist }); + return restore; + } + + private async restartRemoteDiagnosticsRestore( + restore: ReturnType, + restartNow: boolean + ): Promise { + if (!restore) { + return; + } + if (restartNow) { + await restartDebugServer(); + } + } + + private auditRemoteDiagnosticsRestore( + restore: ReturnType, + restartNow: boolean + ): void { + if (!restore) { + return; + } + this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", { + port: restore.port ?? null, + allowlistCount: restore.allowlist?.length ?? 0, host: restore.host ?? "unveraendert", - restartNow - }); - } + restartNow + }); + } public getDebugSetupCheck(): DebugSetupCheckResult { return getDebugSetupCheck(this.storagePaths.baseDir); @@ -510,22 +529,124 @@ export class AppController { this.onHistoryEntryAddedHandler = handler; } - private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void { - let restoredSettings = normalizeSettings(importedSettings); - if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation - && !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { - restoredSettings = normalizeSettings({ - ...restoredSettings, - logStorageLocation: this.settings.logStorageLocation - }); + private prepareImportedLogStorage(restoredSettings: AppSettings): AppSettings { + if (this.settings.logStorageLocation === restoredSettings.logStorageLocation) { + return restoredSettings; } - this.overlayLiveUsageCounters(restoredSettings); - this.settings = restoredSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings, { settingsOnlyImport: true }); - if (restoreRemoteDiagnostics) { - this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true); + const nextDirectory = resolveLogDirectory( + this.storagePaths.baseDir, + this.getDesktopDirectory(), + restoredSettings.logStorageLocation + ); + return prepareLogDirectory(nextDirectory) + ? restoredSettings + : normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation }); + } + + private createBackupImportRollback(includeDownloads: boolean): () => void { + const baseDir = this.storagePaths.baseDir; + const files = [ + this.storagePaths.configFile, + `${this.storagePaths.configFile}.bak`, + `${this.storagePaths.configFile}.tmp`, + `${this.storagePaths.configFile}.bak.tmp`, + path.join(baseDir, "debug_host.txt"), + path.join(baseDir, "debug_port.txt"), + path.join(baseDir, "debug_allowlist.txt") + ]; + if (includeDownloads) { + files.push( + this.storagePaths.sessionFile, + `${this.storagePaths.sessionFile}.bak`, + `${this.storagePaths.sessionFile}.sync.tmp`, + `${this.storagePaths.sessionFile}.async.tmp`, + this.storagePaths.historyFile, + `${this.storagePaths.historyFile}.bak`, + `${this.storagePaths.historyFile}.tmp`, + `${this.storagePaths.historyFile}.bak.tmp`, + this.storagePaths.statisticsFile, + `${this.storagePaths.statisticsFile}.tmp` + ); } + return createFileRollback(files); + } + + private rollbackImportPersistence(rollback: () => void, context: string): void { + try { + rollback(); + } catch (error) { + logger.error(`Backup-Import konnte nach ${context} nicht zurückgerollt werden: ${String(error)}`); + } + } + + private restoreLogStorageAfterFailedImport(location: AppSettings["logStorageLocation"]): void { + try { + this.reconfigureLogStorage(location); + } catch (error) { + logger.error(`Log-Speicherort konnte nach fehlgeschlagenem Backup-Import nicht wiederhergestellt werden: ${String(error)}`); + } + } + + private async applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): Promise { + const barrier = await acquirePersistenceBarrier(); + const previousSettings = this.settings; + let restoredSettings: AppSettings | null = null; + let rollback: (() => void) | null = null; + let remoteRestore: ReturnType = null; + let logStorageChanged = false; + let runtimeApplied = false; + try { + restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings)); + this.overlayLiveUsageCounters(restoredSettings); + rollback = this.createBackupImportRollback(false); + saveSettings(this.storagePaths, restoredSettings); + remoteRestore = restoreRemoteDiagnostics + ? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics) + : null; + if (previousSettings.logStorageLocation !== restoredSettings.logStorageLocation) { + if (!this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { + throw new Error("Log-Speicherort konnte nicht wiederhergestellt werden"); + } + logStorageChanged = true; + } + await this.restartRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics); + this.overlayLiveUsageCounters(restoredSettings); + saveSettings(this.storagePaths, restoredSettings); + this.settings = restoredSettings; + runtimeApplied = true; + this.manager.setSettings(this.settings, { settingsOnlyImport: true }); + this.manager.persistNowSync(); + await barrier.release({ replayBlocked: false }); + } catch (error) { + if (rollback) { + this.rollbackImportPersistence(rollback, "fehlgeschlagenem Settings-Import"); + } + this.settings = previousSettings; + if (runtimeApplied) { + try { + this.manager.setSettings(previousSettings, { settingsOnlyImport: true }); + } catch (runtimeError) { + logger.error(`Backup-Import konnte die vorherigen Laufzeiteinstellungen nicht wiederherstellen: ${String(runtimeError)}`); + } + } + if (logStorageChanged) { + this.restoreLogStorageAfterFailedImport(previousSettings.logStorageLocation); + } + if (remoteRestore && restoreRemoteDiagnostics) { + try { + await restartDebugServer(); + } catch (runtimeError) { + logger.error(`Ferndiagnose konnte nach fehlgeschlagenem Backup-Import nicht wiederhergestellt werden: ${String(runtimeError)}`); + } + } + try { + await barrier.release({ replayBlocked: true }); + } catch (releaseError) { + logger.error(`Blockierte Persistenz konnte nach fehlgeschlagenem Backup-Import nicht fortgesetzt werden: ${String(releaseError)}`); + } + throw error; + } + this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics); } public updateSettings(partial: Partial): AppSettings { @@ -548,17 +669,26 @@ export class AppController { }); } this.overlayLiveUsageCounters(nextSettings); - const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; - const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries - || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; - this.settings = nextSettings; - if (retentionChanged) { - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - } else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") { - saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits()); - } - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); + const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; + const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries + || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; + const rollbackHistory = retentionChanged || historyLimitsChanged ? createHistoryRollback(this.storagePaths) : null; + try { + if (retentionChanged) { + resetHistoryForRetention(this.storagePaths, nextSettings.historyRetentionMode); + } else if (historyLimitsChanged && nextSettings.historyRetentionMode !== "never") { + saveHistory(this.storagePaths, loadHistory(this.storagePaths), { + maxEntries: nextSettings.historyMaxEntries, + maxAgeDays: nextSettings.historyMaxAgeDays + }); + } + saveSettings(this.storagePaths, nextSettings); + } catch (error) { + this.rollbackHistoryAfterFailure(rollbackHistory, "fehlgeschlagener Einstellungsänderung"); + throw error; + } + this.settings = nextSettings; + this.manager.setSettings(this.settings); this.audit("INFO", "Einstellungen aktualisiert", { changedKeys: Object.keys(sanitizedPatch), accountChanges: diffAccountSummary(previousSettings, this.settings) @@ -692,15 +822,30 @@ export class AppController { return status; } - public resetProviderDailyUsage(provider: DebridProvider): AppSettings { - const liveSettings = this.manager.getSettings(); - const nextSettings = normalizeSettings({ - ...liveSettings, - ...resetProviderDailyUsage(liveSettings, provider) - }); - this.settings = nextSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); + public resetProviderDailyUsage(provider: DebridProvider): AppSettings { + const liveSettings = this.manager.getSettings(); + const nextSettings = normalizeSettings({ + ...liveSettings, + ...resetProviderDailyUsage(liveSettings, provider) + }); + const rollback = createFileRollback([ + this.storagePaths.configFile, + `${this.storagePaths.configFile}.bak`, + this.storagePaths.statisticsFile, + `${this.storagePaths.statisticsFile}.tmp` + ]); + try { + saveSettings(this.storagePaths, nextSettings); + this.manager.rebaseStatisticsProviderDailyUsage( + provider, + nextSettings.providerDailyUsageBytes[provider] ?? 0 + ); + } catch (error) { + this.rollbackImportPersistence(rollback, "fehlgeschlagener Provider-Nutzungsrücksetzung"); + throw error; + } + this.settings = nextSettings; + this.manager.setSettings(this.settings); this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); return this.settings; } @@ -1010,6 +1155,12 @@ export class AppController { return this.collectorStore.getState(); } + public saveCollectorStateSync(state: CollectorPersistenceState): CollectorPersistenceState { + this.collectorStore.update(state); + this.collectorStore.flushSync(); + return this.collectorStore.getState(); + } + public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise { return prepareCollectorContainers(filePaths, addedAt); } @@ -1209,7 +1360,7 @@ export class AppController { public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> { const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL); - this.applySettingsOnlyBackup(payload.settings); + await this.applySettingsOnlyBackup(payload.settings); this.audit("INFO", "Online-Sicherung importiert", { kind: "settings-only", accountSummary: buildAccountSummary(this.settings) @@ -1243,7 +1394,7 @@ export class AppController { ); } - public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } { + public async importBackup(data: Buffer, passphrase?: string): Promise<{ restored: boolean; relaunch: boolean; message: string }> { let parsed: Record; try { const json = decryptBackup(data, passphrase); @@ -1259,10 +1410,19 @@ export class AppController { const plan = planBackupImport(parsed); if (!plan.valid) { return { restored: false, relaunch: false, message: plan.message }; - } - const hasSession = plan.restoreDownloads; - - const importedSettings = parsed.settings as AppSettings; + } + const hasSession = plan.restoreDownloads; + let restoredHistory: HistoryEntry[] | undefined; + if (Array.isArray(parsed.history)) { + restoredHistory = (parsed.history as unknown[]) + .map((raw, idx) => normalizeHistoryEntry(raw, idx)) + .filter((entry): entry is HistoryEntry => entry !== null); + if (parsed.history.length > 0 && restoredHistory.length === 0) { + return { restored: false, relaunch: false, message: "Backup-Verlauf enthält keine gültigen Einträge" }; + } + } + + const importedSettings = parsed.settings as AppSettings; const importedSettingsRecord = importedSettings as unknown as Record; const currentSettingsRecord = this.settings as unknown as Record; const SENSITIVE_KEYS: (keyof AppSettings)[] = [ @@ -1286,7 +1446,7 @@ export class AppController { // policy still governs FUTURE completions through the normal path. Do NOT stop the // manager, wipe the session, block persistence or relaunch. if (!hasSession) { - this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true); + await this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true); this.audit("INFO", "Backup importiert (nur Einstellungen)", { accountSummary: buildAccountSummary(this.settings) }); @@ -1294,57 +1454,85 @@ export class AppController { restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" - }; - } - - if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation - && !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { - restoredSettings = normalizeSettings({ - ...restoredSettings, - logStorageLocation: this.settings.logStorageLocation - }); + }; } - this.settings = restoredSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); - - this.manager.stop(); - this.manager.abortAllPostProcessing(); - this.manager.clearPersistTimer(); - cancelPendingAsyncSaves(); - - const restoredSession = normalizeLoadedSessionTransientFields( - normalizeLoadedSession(parsed.session) - ); - saveSession(this.storagePaths, restoredSession); - if (parsed.statistics) { - saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics)); + restoredSettings = this.prepareImportedLogStorage(restoredSettings); + const barrier = await acquirePersistenceBarrier(); + const previousSettings = this.settings; + const previousLogStorageLocation = this.settings.logStorageLocation; + const previousSkipShutdownPersist = this.manager.skipShutdownPersist; + const previousBlockAllPersistence = this.manager.blockAllPersistence; + let rollbackPersistence: (() => void) | null = null; + let remoteRestore: ReturnType = null; + let logStorageChanged = false; + let runtimeApplied = false; + try { + rollbackPersistence = this.createBackupImportRollback(true); + if (restoredHistory) { + replaceHistory(this.storagePaths, restoredHistory); + } + saveSettings(this.storagePaths, restoredSettings); + const restoredSession = normalizeLoadedSessionTransientFields( + normalizeLoadedSession(parsed.session) + ); + saveSession(this.storagePaths, restoredSession); + + if (parsed.statistics) { + saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics)); + } + resetHistoryForRetention(this.storagePaths, restoredSettings.historyRetentionMode); + remoteRestore = this.persistRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics); + if (previousLogStorageLocation !== restoredSettings.logStorageLocation) { + if (!this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { + throw new Error("Log-Speicherort konnte nicht wiederhergestellt werden"); + } + logStorageChanged = true; + } + this.manager.skipShutdownPersist = true; + this.manager.blockAllPersistence = true; + this.settings = restoredSettings; + runtimeApplied = true; + this.manager.setSettings(this.settings); + this.manager.stop(); + this.manager.abortAllPostProcessing(); + this.manager.clearPersistTimer(); + await barrier.release({ replayBlocked: false }); + } catch (error) { + if (rollbackPersistence) { + this.rollbackImportPersistence(rollbackPersistence, "fehlgeschlagenem Full-Backup-Import"); + } + this.manager.skipShutdownPersist = previousSkipShutdownPersist; + this.manager.blockAllPersistence = previousBlockAllPersistence; + this.settings = previousSettings; + if (runtimeApplied) { + try { + this.manager.setSettings(previousSettings, { settingsOnlyImport: true }); + } catch (runtimeError) { + logger.error(`Full-Backup-Import konnte die vorherigen Laufzeiteinstellungen nicht wiederherstellen: ${String(runtimeError)}`); + } + } + if (logStorageChanged) { + this.restoreLogStorageAfterFailedImport(previousLogStorageLocation); + } + try { + await barrier.release({ replayBlocked: true }); + } catch (releaseError) { + logger.error(`Blockierte Persistenz konnte nach fehlgeschlagenem Full-Backup-Import nicht fortgesetzt werden: ${String(releaseError)}`); + } + throw error; } - - if (Array.isArray(parsed.history) && parsed.history.length > 0) { - const normalizedHistory = (parsed.history as unknown[]) - .map((raw, idx) => normalizeHistoryEntry(raw, idx)) - .filter((entry): entry is HistoryEntry => entry !== null); - if (normalizedHistory.length > 0) { - saveHistory(this.storagePaths, normalizedHistory); - logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`); - } - } - - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - - this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false); - - this.manager.skipShutdownPersist = true; - this.manager.blockAllPersistence = true; - logger.info("Backup wiederhergestellt — App startet automatisch neu"); - this.audit("WARN", "Backup importiert", { - historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, - accountSummary: buildAccountSummary(this.settings) - }); - return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; - } + if (restoredHistory) { + logger.info(`Backup: ${restoredHistory.length} History-Einträge wiederhergestellt`); + } + this.auditRemoteDiagnosticsRestore(remoteRestore, false); + logger.info("Backup wiederhergestellt — App startet automatisch neu"); + this.audit("WARN", "Backup importiert", { + historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, + accountSummary: buildAccountSummary(this.settings) + }); + return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; + } public getSessionLogPath(): string | null { return getSessionLogPath(); @@ -1428,16 +1616,16 @@ export class AppController { this.realDebridWebFallbacks.clear(); this.pendingRealDebridWebAccountIds.clear(); this.allDebridWebFallback.dispose(); - this.bestDebridWebFallback.dispose(); + this.bestDebridWebFallback.dispose(); + if (this.settings.historyRetentionMode === "session") { + this.runHistoryLifecycleCleanup("Beenden", () => clearHistory(this.storagePaths)); + } this.shutdownLogStorage(); this.audit("INFO", "App beendet"); shutdownTraceLog(); shutdownAccountRotationLog(); shutdownConversionLog(); shutdownAuditLog(); - if (this.settings.historyRetentionMode === "session") { - clearHistory(this.storagePaths); - } logger.info("App beendet"); } @@ -1516,10 +1704,33 @@ export class AppController { return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; } + private runHistoryLifecycleCleanup(phase: "Start" | "Beenden", cleanup: () => void): void { + try { + cleanup(); + } catch (error) { + logger.warn(`Verlauf konnte beim ${phase} nicht bereinigt werden: ${String(error)}`); + } + } + + private rollbackHistoryAfterFailure(rollback: (() => void) | null, context: string): void { + if (!rollback) { + return; + } + try { + rollback(); + } catch (error) { + logger.error(`Verlauf konnte nach ${context} nicht zurückgerollt werden: ${String(error)}`); + } + } + private recordHistoryEntry(entry: HistoryEntry): void { - const entries = addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); - if (entries[0]?.id === entry.id) { - this.onHistoryEntryAddedHandler?.(entry); + try { + const entries = addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); + if (entries[0]?.id === entry.id) { + this.onHistoryEntryAddedHandler?.(entry); + } + } catch (error) { + logger.error(`Verlaufseintrag konnte nicht gespeichert werden: ${String(error)}`); } } diff --git a/src/main/collector-import.ts b/src/main/collector-import.ts new file mode 100644 index 0000000..ca6fc70 --- /dev/null +++ b/src/main/collector-import.ts @@ -0,0 +1,73 @@ +import type { ParsedPackageInput } from "../shared/types"; +import { COLLECTOR_MAX_LINKS, COLLECTOR_MAX_NAME_LENGTH, COLLECTOR_MAX_PACKAGES } from "../shared/collector"; +import { mergePackageInputs } from "./link-parser"; +import { isHttpLink } from "./utils"; + +const invalidQueueExport = (): never => { + throw new Error("Der Queue-Export ist ungültig."); +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + return Object.keys(value).every((key) => allowedKeys.includes(key)); +} + +export function parseCollectorQueueExport(rawText: string): ParsedPackageInput[] | null { + const text = String(rawText || "").trim(); + if (!text) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + if (!text.startsWith("{") && !text.startsWith("[")) return null; + throw new Error("Der Queue-Export enthält ungültiges JSON."); + } + if (!isRecord(parsed) + || !hasOnlyKeys(parsed, ["version", "packages"]) + || parsed.version !== 1 + || !Array.isArray(parsed.packages)) { + return invalidQueueExport(); + } + if (parsed.packages.length > COLLECTOR_MAX_PACKAGES) { + throw new Error(`Der Queue-Export überschreitet das Collector-Limit von ${COLLECTOR_MAX_PACKAGES.toLocaleString("de-DE")} Paketen.`); + } + + let linkCount = 0; + const packages: ParsedPackageInput[] = parsed.packages.map((value) => { + if (!isRecord(value) + || !hasOnlyKeys(value, ["name", "links", "fileNames"]) + || typeof value.name !== "string" + || value.name.trim().length === 0 + || value.name.length > COLLECTOR_MAX_NAME_LENGTH + || !Array.isArray(value.links) + || (value.fileNames !== undefined && !Array.isArray(value.fileNames))) { + return invalidQueueExport(); + } + const fileNamesRaw = value.fileNames as unknown[] | undefined; + if (fileNamesRaw && fileNamesRaw.length > value.links.length) return invalidQueueExport(); + linkCount += value.links.length; + if (linkCount > COLLECTOR_MAX_LINKS) { + throw new Error(`Der Queue-Export überschreitet das Collector-Limit von ${COLLECTOR_MAX_LINKS.toLocaleString("de-DE")} Links.`); + } + const links = value.links.map((entry) => { + if (typeof entry !== "string" || entry.length > 32767 || !isHttpLink(entry)) return invalidQueueExport(); + return entry.trim(); + }); + const fileNames = links.map((_, index) => { + const entry = fileNamesRaw?.[index]; + if (entry === undefined) return ""; + if (typeof entry !== "string" || entry.length > COLLECTOR_MAX_NAME_LENGTH) return invalidQueueExport(); + return entry.trim(); + }); + return { + name: value.name, + links, + ...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {}) + }; + }); + + return mergePackageInputs(packages).filter((pkg) => pkg.links.length > 0); +} diff --git a/src/main/collector-inspection.ts b/src/main/collector-inspection.ts index 2d6bcbd..5957f00 100644 --- a/src/main/collector-inspection.ts +++ b/src/main/collector-inspection.ts @@ -7,7 +7,7 @@ import type { CollectorPackage, CollectorTextPreparationRequest } from "../shared/collector"; -import { serializeCollectorPackages } from "../shared/collector"; +import { COLLECTOR_MAX_NAME_LENGTH, serializeCollectorPackages } from "../shared/collector"; import { extractHosterFromUrl } from "../shared/hoster"; import type { AppSettings, ParsedPackageInput } from "../shared/types"; import { @@ -20,9 +20,17 @@ import { type OneFichierCheckResult } from "./debrid"; import { importDlcContainers } from "./container"; +import { parseCollectorQueueExport } from "./collector-import"; import { parseCollectorInput } from "./link-parser"; import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils"; +function normalizeCollectorName(value: string): string { + const sanitized = sanitizeFilename(value); + return sanitized.length <= COLLECTOR_MAX_NAME_LENGTH + ? sanitized + : sanitizeFilename(sanitized.slice(0, COLLECTOR_MAX_NAME_LENGTH)); +} + export interface CollectorInspectionDependencies { checkDdownload?: typeof checkDdownloadOnline; checkOneFichier?: (links: string[]) => Promise>; @@ -82,7 +90,7 @@ function readableHosterName(hoster: string): string { } export function inferCollectorPackageName(fileName: string, hoster: string): string { - const safeName = sanitizeFilename(fileName || ""); + const safeName = normalizeCollectorName(fileName || ""); if (!safeName || looksLikeOpaqueFilename(safeName)) return readableHosterName(hoster); const patterns = [ /^(.*)\.part\d+\.rar$/i, @@ -93,10 +101,10 @@ export function inferCollectorPackageName(fileName: string, hoster: string): str ]; for (const pattern of patterns) { const match = safeName.match(pattern); - if (match?.[1]?.trim()) return sanitizeFilename(match[1]); + if (match?.[1]?.trim()) return normalizeCollectorName(match[1]); } const stem = path.parse(safeName).name.trim(); - return sanitizeFilename(stem || readableHosterName(hoster)); + return normalizeCollectorName(stem || readableHosterName(hoster)); } function countInputLines(rawText: string): { invalidCount: number; duplicateCount: number } { @@ -129,8 +137,8 @@ function preparePackages(packages: ParsedPackageInput[], addedAt: number, nameSo const url = String(pkg.links[index] || "").trim(); if (!url || seen.has(url)) continue; seen.add(url); - const explicitFileName = sanitizeFilename(String(pkg.fileNames?.[index] || "").trim()); - const fileName = explicitFileName || filenameFromUrl(url); + const explicitFileName = normalizeCollectorName(String(pkg.fileNames?.[index] || "").trim()); + const fileName = explicitFileName || normalizeCollectorName(filenameFromUrl(url)); links.push({ id: stableId("link", url), url, @@ -143,13 +151,21 @@ function preparePackages(packages: ParsedPackageInput[], addedAt: number, nameSo }); } if (links.length === 0) continue; - const name = sanitizeFilename(pkg.name || inferCollectorPackageName(links[0].fileName, links[0].hoster)); + const name = normalizeCollectorName(pkg.name || inferCollectorPackageName(links[0].fileName, links[0].hoster)); prepared.push({ id: packageId(links), name, nameSource, links, addedAt }); } return prepared; } export function prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult { + const queueExport = parseCollectorQueueExport(request.rawText); + if (queueExport) { + return { + packages: preparePackages(queueExport, request.addedAt, "explicit"), + invalidCount: 0, + duplicateCount: 0 + }; + } const parsed = parseCollectorInput(request.rawText, ""); const nameSource = /^#\s*package\s*:/im.test(request.rawText) ? "explicit" : "inferred"; return { @@ -235,9 +251,10 @@ export async function enrichCollectorPackages( for (const [url, result] of results) { const link = linksByUrl.get(url); if (!link) continue; + const fileName = result.fileName ? normalizeCollectorName(result.fileName) : ""; link.availability = result.online ? "online" : "offline"; - link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; - if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + link.status = result.online ? (fileName ? "ready" : "unknown") : "offline"; + if (fileName) link.fileName = fileName; if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; progress.queue(url); } @@ -248,9 +265,10 @@ export async function enrichCollectorPackages( const result = await checkRapidgator(url).catch(() => null); const link = linksByUrl.get(url); if (!link || !result) return; + const fileName = result.fileName ? normalizeCollectorName(result.fileName) : ""; link.availability = result.online ? "online" : "offline"; - link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; - if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + link.status = result.online ? (fileName ? "ready" : "unknown") : "offline"; + if (fileName) link.fileName = fileName; if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; progress.queue(url); }); @@ -258,9 +276,10 @@ export async function enrichCollectorPackages( const result = await checkDdownload(url).catch(() => null); const link = linksByUrl.get(url); if (!link || !result) return; + const fileName = result.fileName ? normalizeCollectorName(result.fileName) : ""; link.availability = result.online ? "online" : "offline"; - link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; - if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + link.status = result.online ? (fileName ? "ready" : "unknown") : "offline"; + if (fileName) link.fileName = fileName; if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; progress.queue(url); }); @@ -268,7 +287,7 @@ export async function enrichCollectorPackages( ? resolveFilenames(genericLinks, (url, fileName) => { const link = linksByUrl.get(url); if (!link || !fileName) return; - link.fileName = sanitizeFilename(fileName); + link.fileName = normalizeCollectorName(fileName); link.status = "ready"; progress.queue(url); }).catch(() => new Map()) @@ -283,7 +302,7 @@ export async function enrichCollectorPackages( for (const [url, fileName] of genericResults) { const link = linksByUrl.get(url); if (!link || !fileName) continue; - link.fileName = sanitizeFilename(fileName); + link.fileName = normalizeCollectorName(fileName); link.status = "ready"; progress.queue(url); } diff --git a/src/main/collector-store.ts b/src/main/collector-store.ts index 9bd6047..e183600 100644 --- a/src/main/collector-store.ts +++ b/src/main/collector-store.ts @@ -3,12 +3,12 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { + COLLECTOR_MAX_PERSISTENCE_BYTES, validateCollectorPersistenceState, type CollectorPersistenceState } from "../shared/collector"; import { logger } from "./logger"; -const maxPersistenceBytes = 64 * 1024 * 1024; const writeDelayMs = 300; const renameRetryDelaysMs = [15, 40, 90]; @@ -62,7 +62,7 @@ async function renameWithRetry(tempPath: string, filePath: string): Promise maxPersistenceBytes) return null; + if (!stat.isFile() || stat.size > COLLECTOR_MAX_PERSISTENCE_BYTES) return null; const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; if (Object.keys(parsed).some((key) => !["version", "packages", "collapsedPackageIds", "updatedAt"].includes(key))) return null; @@ -163,7 +163,7 @@ export class CollectorStore { public update(state: CollectorPersistenceState): void { const nextState = validateCollectorPersistenceState(state); const nextUpdatedAt = Math.max(Date.now(), this.updatedAt + 1); - if (Buffer.byteLength(serializeState(nextState, nextUpdatedAt), "utf8") > maxPersistenceBytes) { + if (Buffer.byteLength(serializeState(nextState, nextUpdatedAt), "utf8") > COLLECTOR_MAX_PERSISTENCE_BYTES) { throw new Error("Linksammler-Speicherzustand ist zu groß"); } this.state = nextState; diff --git a/src/main/constants.ts b/src/main/constants.ts index 90e7b56..1d25e77 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -111,6 +111,7 @@ export function defaultSettings(): AppSettings { clipboardWatch: false, minimizeToTray: false, theme: "dark" as const, + themePreference: "dark" as const, logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 61768a0..b238d3b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -90,8 +90,10 @@ import { loadStatisticsLedger, normalizeStatisticsLedger, projectStatisticsLedger, + rebaseStatisticsProviderSeedBaseline, saveStatisticsLedger, - seedStatisticsDayProviderBytes + seedStatisticsDayProviderBytes, + suppressStatisticsProviderSeedForDay } from "./statistics-ledger"; import { finalizePackageResult, projectPackageFailureCategory } from "./package-telemetry"; import type { NotificationEvent } from "./notification-outbox"; @@ -1910,9 +1912,10 @@ export class DownloadManager extends EventEmitter { this.settingsSnapshotCacheAt = 0; } - private lastEmittedItemHashes = new Map(); - private lastEmittedPackageHashes = new Map(); - private firstEmitDone = false; + private lastEmittedItemHashes = new Map(); + private lastEmittedPackageHashes = new Map(); + private snapshotRevision = 0; + private firstEmitDone = false; private lastFullEmitAt = 0; private static readonly FULL_RESYNC_INTERVAL_MS = 30000; @@ -2787,7 +2790,20 @@ export class DownloadManager extends EventEmitter { const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0; const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0; const remaining = totalItems - doneItems; - const eta = remaining > 0 && rate > 0 ? remaining / rate : -1; + const eta = remaining > 0 && rate > 0 ? remaining / rate : -1; + let runRemainingBytes = 0; + let runRemainingUnknownItems = 0; + if (this.session.running) { + for (const itemId of this.runItemIds) { + const item = this.session.items[itemId]; + if (!item || isFinishedStatus(item.status)) continue; + if (item.totalBytes && item.totalBytes > 0) { + runRemainingBytes += Math.max(0, item.totalBytes - Math.max(0, item.downloadedBytes)); + } else { + runRemainingUnknownItems += 1; + } + } + } const reconnectMs = Math.max(0, this.session.reconnectUntil - now); @@ -2805,6 +2821,7 @@ export class DownloadManager extends EventEmitter { : null; return { + snapshotRevision: ++this.snapshotRevision, rotationEvents: getRecentRotationEvents(40), accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now), settings: rendererState.settings, @@ -2827,8 +2844,10 @@ export class DownloadManager extends EventEmitter { for (const [pid, bytes] of this.speedBytesPerPackage) { out[pid] = Math.floor(bytes / SPEED_WINDOW_SECONDS); } - return out; - })() + return out; + })(), + runRemainingBytes, + runRemainingUnknownItems }; } @@ -3036,7 +3055,7 @@ export class DownloadManager extends EventEmitter { return Object.values(this.session.packages).some((pkg) => pathKey(pkg.outputDir) === key); } - public resetSessionStats(): void { + public resetSessionStats(): void { const now = nowMs(); this.session.totalDownloadedBytes = 0; this.sessionDownloadedBytes = 0; @@ -3053,24 +3072,77 @@ export class DownloadManager extends EventEmitter { this.summary = null; this.invalidateStatsCache(); this.persistSoon(); - this.emitState(true); - } - - public resetDownloadStats(): void { - this.settings.totalDownloadedAllTime = 0; - this.settings.totalCompletedFilesAllTime = 0; - this.settings.providerTotalUsageBytes = {}; - this.settings.debridLinkApiKeyTotalUsageBytes = {}; - this.statisticsLedger = createStatisticsLedger(); - this.rollingAccountStatistics.reset(this.statisticsLedger); + this.emitState(true); + } + + public rebaseStatisticsProviderDailyUsage(provider: DebridProvider, providerUsageBytes: number): void { + const rebasedAt = nowMs(); + const rebased = rebaseStatisticsProviderSeedBaseline( + this.statisticsLedger, + provider, + providerUsageBytes, + rebasedAt + ); + saveStatisticsLedger(this.storagePaths.statisticsFile, rebased); + this.statisticsLedger = rebased; + this.rollingAccountStatistics.reset(rebased, rebasedAt); this.statisticsDirty = false; this.statisticsUrgent = false; - saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); - this.lastSettingsPersistAt = nowMs(); - saveSettings(this.storagePaths, this.settings); - this.invalidateStatsCache(); - this.emitState(true); - } + this.invalidateStatsCache(); + this.emitState(true); + } + + public resetDownloadStats(): void { + const resetAt = nowMs(); + const previousSettings = JSON.parse(JSON.stringify(this.settings)) as AppSettings; + const previousLedger = normalizeStatisticsLedger(this.statisticsLedger, resetAt); + const nextSettings = JSON.parse(JSON.stringify(previousSettings)) as AppSettings; + const currentUsageDay = getProviderUsageDayKey(resetAt); + if (nextSettings.providerDailyUsageDay !== currentUsageDay) { + nextSettings.providerDailyUsageDay = currentUsageDay; + nextSettings.providerDailyUsageBytes = {}; + nextSettings.debridLinkApiKeyDailyUsageBytes = {}; + nextSettings.megaDebridAccountDailyUsageBytes = {}; + nextSettings.realDebridAccountDailyUsageBytes = {}; + } + nextSettings.totalDownloadedAllTime = 0; + nextSettings.totalCompletedFilesAllTime = 0; + nextSettings.providerTotalUsageBytes = {}; + nextSettings.debridLinkApiKeyTotalUsageBytes = {}; + const nextLedger = suppressStatisticsProviderSeedForDay( + createStatisticsLedger(resetAt), + resetAt, + nextSettings.providerDailyUsageBytes + ); + saveStatisticsLedger(this.storagePaths.statisticsFile, nextLedger); + try { + saveSettings(this.storagePaths, nextSettings); + } catch (error) { + const rollbackErrors: string[] = []; + try { + saveStatisticsLedger(this.storagePaths.statisticsFile, previousLedger); + } catch (rollbackError) { + rollbackErrors.push(`Statistik: ${compactErrorText(rollbackError)}`); + } + try { + saveSettings(this.storagePaths, previousSettings); + } catch (rollbackError) { + rollbackErrors.push(`Einstellungen: ${compactErrorText(rollbackError)}`); + } + if (rollbackErrors.length > 0) { + logger.error(`Statistik-Reset-Rollback fehlgeschlagen: ${rollbackErrors.join(" | ")}`); + } + throw error; + } + Object.assign(this.settings, nextSettings); + this.statisticsLedger = nextLedger; + this.rollingAccountStatistics.reset(nextLedger, resetAt); + this.statisticsDirty = false; + this.statisticsUrgent = false; + this.lastSettingsPersistAt = resetAt; + this.invalidateStatsCache(); + this.emitState(true); + } public renamePackage(packageId: string, newName: string): void { const pkg = this.session.packages[packageId]; @@ -7062,9 +7134,15 @@ export class DownloadManager extends EventEmitter { } catch (error) { this.statisticsDirty = true; logger.warn(`Statistik konnte nicht gespeichert werden: ${compactErrorText(error)}`); + try { + saveSettings(this.storagePaths, this.settings); + this.lastSettingsPersistAt = now; + } catch (settingsError) { + logger.warn(`Statistik-Fallback konnte nicht gespeichert werden: ${compactErrorText(settingsError)}`); + } } } - } + } public persistNowSync(): void { this.clearPersistTimer(); diff --git a/src/main/link-parser.ts b/src/main/link-parser.ts index 79365bc..d52ef8b 100644 --- a/src/main/link-parser.ts +++ b/src/main/link-parser.ts @@ -1,17 +1,18 @@ import { ParsedPackageInput } from "../shared/types"; -import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils"; +import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename } from "./utils"; export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] { - const grouped = new Map }>(); + const grouped = new Map; fileNameByLink: Map }>(); for (const pkg of packages) { const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links)); - const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map() }; + const current = grouped.get(name) ?? { links: [], linkSet: new Set(), fileNameByLink: new Map() }; for (let index = 0; index < pkg.links.length; index += 1) { const link = String(pkg.links[index] || "").trim(); if (!link) { continue; } - if (!current.links.includes(link)) { + if (!current.linkSet.has(link)) { + current.linkSet.add(link); current.links.push(link); } const rawFileName = String(pkg.fileNames?.[index] || "").trim(); @@ -23,7 +24,7 @@ export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackag grouped.set(name, current); } return Array.from(grouped.entries()).map(([name, entry]) => { - const links = uniquePreserveOrder(entry.links); + const links = entry.links; const fileNames = links.map((link) => entry.fileNameByLink.get(link) || ""); return { name, diff --git a/src/main/main.ts b/src/main/main.ts index 2d95ab5..e46f9d2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -207,6 +207,32 @@ function onTrusted(channel: string, listener: (event: I listener(event, ...(args as TArgs)); }); } + +export function saveCollectorStateFromSyncIpc( + target: Pick, + event: Pick, + value: unknown +): void { + try { + target.saveCollectorStateSync(validateCollectorPersistenceState(value)); + event.returnValue = true; + } catch (error) { + logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`); + event.returnValue = false; + } +} + +export function getCollectorStateFromSyncIpc( + target: Pick, + event: Pick +): void { + try { + event.returnValue = validateCollectorPersistenceState(target.getCollectorState()); + } catch (error) { + logger.warn(`Synchrones Laden des Linksammlers fehlgeschlagen: ${String(error)}`); + event.returnValue = null; + } +} // Single owner of the scheduled-start timer. startOnPast: a past time entered // interactively starts right away; at boot a stale past time is cleared instead @@ -579,17 +605,14 @@ function registerIpcHandlers(): void { }); }); handleTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE, () => controller.getCollectorState()); + onTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC, (event: IpcMainEvent) => { + getCollectorStateFromSyncIpc(controller, event); + }); handleTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE, (_event: IpcMainInvokeEvent, value: unknown) => { return controller.saveCollectorState(validateCollectorPersistenceState(value)); }); onTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, (event: IpcMainEvent, value: unknown) => { - try { - controller.saveCollectorState(validateCollectorPersistenceState(value)); - event.returnValue = true; - } catch (error) { - logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`); - event.returnValue = false; - } + saveCollectorStateFromSyncIpc(controller, event, value); }); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { @@ -1022,7 +1045,7 @@ function registerIpcHandlers(): void { return { restored: false, relaunch: false, message: "Keine Backup-Datei ausgewählt" }; } const passphrase = typeof rawPassphrase === "string" ? rawPassphrase : undefined; - const importResult = controller.importBackup(data, passphrase); + const importResult = await controller.importBackup(data, passphrase); // Only a full restore (queue swapped) needs the auto-relaunch. A settings- // only import applied live — relaunching would be pointless and would drop // the running queue. diff --git a/src/main/notification-outbox.ts b/src/main/notification-outbox.ts index 4752a99..4bdd392 100644 --- a/src/main/notification-outbox.ts +++ b/src/main/notification-outbox.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { DiscordEmbedFieldPayload } from "./notify"; import { projectPackageFailureCategory } from "./package-telemetry"; import type { FailurePhase } from "../shared/types"; +import { logger } from "./logger"; export type NotificationEventType = | "package_completed" @@ -71,6 +72,7 @@ const EVENT_TYPES = new Set([ const MAX_EVENTS = 250; const MAX_RETRY_DELAY_MS = 10 * 60 * 1000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000; +const RENAME_RETRY_DELAYS_MS = [15, 40, 90]; const PACKAGE_FAILURE_EVENT_TYPES = new Set([ "package_partial", "package_failed", @@ -84,6 +86,40 @@ const PACKAGE_FAILURE_PHASES = new Map([ ["Nachbearbeitung", "postprocess"] ]); +function renameErrorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; +} + +function isTransientRenameError(error: unknown): boolean { + return ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error)); +} + +function renameFileSyncWithRetry(tempPath: string, filePath: string): void { + for (let attempt = 0; ; attempt += 1) { + try { + fs.renameSync(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_RETRY_DELAYS_MS[attempt]); + } + } +} + +async function renameFileWithRetry(tempPath: string, filePath: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await fsp.rename(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error; + await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_DELAYS_MS[attempt])); + } + } +} + function finiteInteger(value: unknown, fallback = 0): number { const numeric = Number(value); return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback; @@ -188,8 +224,12 @@ export class NotificationOutbox { this.clock = options.now || Date.now; this.autoDrain = Boolean(options.autoDrain); this.load(); - if (this.autoDrain && this.events.length > 0) { - this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock())); + if (this.autoDrain) { + if (this.persistenceRequired) { + this.schedulePersistenceRetry(retryDelayMs(this.persistenceRetryAttempts)); + } else if (this.events.length > 0) { + this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock())); + } } } @@ -381,7 +421,18 @@ export class NotificationOutbox { this.lastSuccessAt = 0; this.lastFailureAt = 0; } - this.persistSync(this.clock()); + try { + this.persistSync(this.clock()); + } catch (error) { + this.persistenceRequired = true; + this.persistenceRetryAttempts += 1; + const code = renameErrorCode(error) || "UNKNOWN"; + if (isTransientRenameError(error)) { + logger.warn(`Notification-Outbox beim Laden vorübergehend gesperrt (${code}); Persistenz wird erneut versucht`); + } else { + logger.error(`Notification-Outbox konnte beim Laden nicht gespeichert werden (${code}): ${String(error)}`); + } + } } private enforceLimits(now: number): void { @@ -411,7 +462,7 @@ export class NotificationOutbox { try { await fsp.mkdir(path.dirname(this.filePath), { recursive: true }); await fsp.writeFile(tempPath, JSON.stringify(state), "utf8"); - await fsp.rename(tempPath, this.filePath); + await renameFileWithRetry(tempPath, this.filePath); this.persistenceRequired = false; this.persistenceRetryAttempts = 0; } catch (error) { @@ -436,7 +487,7 @@ export class NotificationOutbox { }; try { fs.writeFileSync(tempPath, JSON.stringify(state), "utf8"); - fs.renameSync(tempPath, this.filePath); + renameFileSyncWithRetry(tempPath, this.filePath); } catch (error) { try { fs.rmSync(tempPath, { force: true }); diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index 7c7374d..383209b 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -94,6 +94,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") { invalid(); } + if (key === "themePreference" && entry !== "light" && entry !== "dark" && entry !== "system") { + invalid(); + } if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) { invalid(); } diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 1d6ec8f..6aa2c06 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -209,6 +209,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings clipboardWatch: settings.clipboardWatch, minimizeToTray: settings.minimizeToTray, theme: settings.theme, + themePreference: settings.themePreference, logStorageLocation: settings.logStorageLocation, collapseNewPackages: settings.collapseNewPackages, animatePackageDisclosure: settings.animatePackageDisclosure, diff --git a/src/main/statistics-ledger.ts b/src/main/statistics-ledger.ts index 7338e4a..36a160a 100644 --- a/src/main/statistics-ledger.ts +++ b/src/main/statistics-ledger.ts @@ -54,6 +54,14 @@ function finiteNonNegative(value: unknown): number { return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0; } +function finiteInteger(value: unknown): number { + const number = Number(value); + if (!Number.isFinite(number)) { + return 0; + } + return Math.max(-Number.MAX_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(number))); +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -176,6 +184,20 @@ function normalizeProviderBucket(value: unknown): StatisticsProviderBucket { }; } +function normalizeProviderSeedBaseline(value: unknown): Partial> | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const baseline: Partial> = {}; + for (const [provider, bytes] of Object.entries(record)) { + if (providers.has(provider as DebridProvider)) { + baseline[provider as DebridProvider] = finiteInteger(bytes); + } + } + return baseline; +} + function normalizeDay(value: unknown): StatisticsDayBucket | null { const record = asRecord(value); const day = String(record?.day || ""); @@ -201,7 +223,7 @@ function normalizeDay(value: unknown): StatisticsDayBucket | null { } export function createStatisticsLedger(now = Date.now()): StatisticsLedger { - return { version: 2, startedAt: now, days: [], minutes: [] }; + return { version: 2, startedAt: now, minuteTrackingStartedAt: now, days: [], minutes: [] }; } export function normalizeStatisticsLedger(value: unknown, now = Date.now()): StatisticsLedger { @@ -216,9 +238,23 @@ export function normalizeStatisticsLedger(value: unknown, now = Date.now()): Sta byDay.set(day.day, day); } } + const providerSeedSuppressedDay = /^\d{4}-\d{2}-\d{2}$/.test(String(record.providerSeedSuppressedDay || "")) + ? String(record.providerSeedSuppressedDay) + : undefined; + const providerBytesOnlyDays = [...new Set( + (Array.isArray(record.providerBytesOnlyDays) ? record.providerBytesOnlyDays : []) + .map((day) => String(day || "")) + .filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day) && byDay.has(day)) + )].sort(); return { version: 2, startedAt: finiteNonNegative(record.startedAt) || now, + minuteTrackingStartedAt: Math.min(finiteNonNegative(record.minuteTrackingStartedAt) || now, now), + providerSeedSuppressedDay, + providerSeedBaselineBytes: providerSeedSuppressedDay + ? normalizeProviderSeedBaseline(record.providerSeedBaselineBytes) + : undefined, + providerBytesOnlyDays: providerBytesOnlyDays.length > 0 ? providerBytesOnlyDays : undefined, days: [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)), minutes: normalizeMinutes(record.minutes, now) }; @@ -401,16 +437,58 @@ function mutableDay(ledger: StatisticsLedger, epochMs: number): StatisticsDayBuc return day; } +function markProviderBytesOnlyDay(ledger: StatisticsLedger, dayKey: string): StatisticsLedger { + return { + ...ledger, + providerBytesOnlyDays: [...new Set([...(ledger.providerBytesOnlyDays ?? []), dayKey])].sort() + }; +} + export function seedStatisticsDayProviderBytes( ledger: StatisticsLedger, usage: Partial>, epochMs = Date.now() ): StatisticsLedger { - return updateDay(ledger, epochMs, (day) => { + const normalized = normalizeStatisticsLedger(ledger, epochMs); + const dayKey = getProviderUsageDayKey(epochMs); + if (normalized.providerSeedSuppressedDay === dayKey) { + const baseline = normalized.providerSeedBaselineBytes; + if (!baseline) { + return normalized; + } + const deltas: Partial> = {}; + for (const [provider, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) { + if (!providers.has(provider)) continue; + const delta = Math.max(0, finiteNonNegative(rawBytes) - finiteInteger(baseline[provider])); + if (delta > 0) { + deltas[provider] = delta; + } + } + if (Object.keys(deltas).length === 0) { + return normalized; + } + let recovered = false; + const seeded = updateDay(normalized, epochMs, (day) => { + for (const [provider, bytes] of Object.entries(deltas) as Array<[DebridProvider, number]>) { + const existing = day.providers[provider] ?? emptyProviderBucket(); + recovered ||= bytes > existing.bytes; + existing.bytes = Math.max(existing.bytes, bytes); + day.providers[provider] = existing; + } + day.downloadedBytes = Math.max( + day.downloadedBytes, + Object.values(day.providers).reduce((total, bucket) => total + finiteNonNegative(bucket?.bytes), 0) + ); + }); + return recovered ? markProviderBytesOnlyDay(seeded, dayKey) : seeded; + } + let recovered = false; + const seeded = updateDay(normalized, epochMs, (day) => { for (const [provider, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) { if (!providers.has(provider)) continue; const bytes = finiteNonNegative(rawBytes); const existing = day.providers[provider] ?? emptyProviderBucket(); + recovered ||= bytes > existing.bytes; existing.bytes = Math.max(existing.bytes, bytes); day.providers[provider] = existing; } @@ -419,6 +497,54 @@ export function seedStatisticsDayProviderBytes( Object.values(day.providers).reduce((total, bucket) => total + finiteNonNegative(bucket?.bytes), 0) ); }); + const current = normalized.providerSeedSuppressedDay && normalized.providerSeedSuppressedDay !== dayKey + ? { ...seeded, providerSeedSuppressedDay: undefined, providerSeedBaselineBytes: undefined } + : seeded; + return recovered ? markProviderBytesOnlyDay(current, dayKey) : current; +} + +export function rebaseStatisticsProviderSeedBaseline( + ledger: StatisticsLedger, + provider: DebridProvider, + providerUsageBytes: number, + epochMs = Date.now() +): StatisticsLedger { + const normalized = normalizeStatisticsLedger(ledger, epochMs); + const dayKey = getProviderUsageDayKey(epochMs); + if (!providers.has(provider)) { + return normalized; + } + const activeBaseline = normalized.providerSeedSuppressedDay === dayKey + ? normalized.providerSeedBaselineBytes + : {}; + if (normalized.providerSeedSuppressedDay === dayKey && activeBaseline === undefined) { + return normalized; + } + const providerBytes = finiteNonNegative( + normalized.days.find((day) => day.day === dayKey)?.providers[provider]?.bytes + ); + return { + ...normalized, + providerSeedSuppressedDay: dayKey, + providerSeedBaselineBytes: { + ...activeBaseline, + [provider]: finiteNonNegative(providerUsageBytes) - providerBytes + } + }; +} + +export function suppressStatisticsProviderSeedForDay( + ledger: StatisticsLedger, + epochMs = Date.now(), + baselineUsage?: Partial> +): StatisticsLedger { + return { + ...normalizeStatisticsLedger(ledger, epochMs), + providerSeedSuppressedDay: getProviderUsageDayKey(epochMs), + providerSeedBaselineBytes: baselineUsage === undefined + ? undefined + : normalizeProviderSeedBaseline(baselineUsage) ?? {} + }; } export function recordStatisticsBytes( diff --git a/src/main/storage.ts b/src/main/storage.ts index 4a39591..a6124cb 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; -import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types"; +import { AppSettings, AppTheme, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState, ThemePreference } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts"; import { defaultSettings } from "./constants"; @@ -34,7 +34,8 @@ const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); const VALID_SPEED_MODES = new Set(["global", "per_download"]); -const VALID_THEMES = new Set(["dark", "light"]); +const VALID_THEMES = new Set(["dark", "light"]); +const VALID_THEME_PREFERENCES = new Set(["dark", "light", "system"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_HISTORY_RETENTION_MODES = new Set(["never", "session", "permanent"]); const VALID_LOG_STORAGE_LOCATIONS = new Set(["appdata", "desktop"]); @@ -424,6 +425,8 @@ function migrateUpdateRepo(raw: string, fallback: string): string { export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); + const theme = VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme; + const themePreference = VALID_THEME_PREFERENCES.has(settings.themePreference) ? settings.themePreference : theme; const directorySettings = migrateLegacyDefaultDirectories(settings, defaults); const legacySuccessMode = settings.notifyOnPackageCompleted === true ? "individual" : "digest"; const notifyPackageSuccessMode = settings.notifyPackageSuccessMode === "individual" || settings.notifyPackageSuccessMode === "digest" @@ -633,7 +636,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, - theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, + theme, + themePreference, bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion), columnOrderVersion: 3, @@ -722,7 +726,7 @@ export interface StoragePaths { collectorFile: string; } -export function createStoragePaths(baseDir: string): StoragePaths { +export function createStoragePaths(baseDir: string): StoragePaths { return { baseDir, configFile: path.join(baseDir, "rd_downloader_config.json"), @@ -734,8 +738,40 @@ export function createStoragePaths(baseDir: string): StoragePaths { collectorFile: path.join(baseDir, "rd_collector_state.json") }; } - -function ensureBaseDir(baseDir: string): void { + +export function createFileRollback(filePaths: readonly string[]): () => void { + const snapshots = [...new Set(filePaths)].map((filePath) => ({ + filePath, + payload: fs.existsSync(filePath) ? fs.readFileSync(filePath) : null + })); + return () => { + let firstError: unknown = null; + for (const snapshot of snapshots) { + try { + if (snapshot.payload === null) { + fs.rmSync(snapshot.filePath, { force: true }); + continue; + } + fs.mkdirSync(path.dirname(snapshot.filePath), { recursive: true }); + const tempPath = `${snapshot.filePath}.${process.pid}.${randomUUID()}.rollback.tmp`; + try { + fs.writeFileSync(tempPath, snapshot.payload); + syncRenameWithExdevFallback(tempPath, snapshot.filePath); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } + } catch (error) { + firstError ??= error; + } + } + if (firstError) { + throw firstError; + } + }; +} + +function ensureBaseDir(baseDir: string): void { try { fs.mkdirSync(baseDir, { recursive: true }); } catch (error) { @@ -890,6 +926,9 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null { ...defaultSettings(), ...migrated, language: migratedLanguage, + themePreference: Object.prototype.hasOwnProperty.call(parsed, "themePreference") + ? migrated.themePreference + : VALID_THEMES.has(migrated.theme) ? migrated.theme : defaultSettings().themePreference, columnOrderVersion: parsed.columnOrderVersion } as AppSettings; if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridApiDisabledAccountIds")) { @@ -1295,6 +1334,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void { let asyncSettingsSaveRunning = false; let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings; generation: number } | null = null; let syncSettingsSaveGeneration = 0; +let activeSettingsSave: Promise | null = null; async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, generation: number): Promise { await fs.promises.mkdir(paths.baseDir, { recursive: true }); @@ -1315,32 +1355,14 @@ async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, return; } try { - await fsp.rename(backupTempPath, `${paths.configFile}.bak`); - } catch (renameError: unknown) { - if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { - await fsp.copyFile(backupTempPath, `${paths.configFile}.bak`); - await fsp.rm(backupTempPath, { force: true }).catch(() => {}); - } else { - await fsp.rm(backupTempPath, { force: true }).catch(() => {}); - throw renameError; - } + syncRenameWithExdevFallback(backupTempPath, `${paths.configFile}.bak`); + syncRenameWithExdevFallback(tempPath, paths.configFile); + } catch (error) { + try { fs.rmSync(backupTempPath, { force: true }); } catch { } + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; } - try { - await fsp.rename(tempPath, paths.configFile); - } catch (renameError: unknown) { - if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { - if (generation < syncSettingsSaveGeneration) { - await fsp.rm(tempPath, { force: true }).catch(() => {}); - return; - } - await fsp.copyFile(tempPath, paths.configFile); - await fsp.rm(tempPath, { force: true }).catch(() => {}); - } else { - await fsp.rm(tempPath, { force: true }).catch(() => {}); - throw renameError; - } - } -} +} async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise { if (asyncSettingsSaveRunning) { @@ -1348,23 +1370,30 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettin return; } asyncSettingsSaveRunning = true; - try { - await writeSettingsPayload(paths, settings, generation); - } catch (error) { - logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`); - } finally { - asyncSettingsSaveRunning = false; - if (asyncSettingsSaveQueued) { + const operation = writeSettingsPayload(paths, settings, generation).catch((error) => { + logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`); + }).finally(() => { + asyncSettingsSaveRunning = false; + if (activeSettingsSave === operation) { + activeSettingsSave = null; + } + if (asyncSettingsSaveQueued) { const queued = asyncSettingsSaveQueued; asyncSettingsSaveQueued = null; void saveSettingsPayloadAsync(queued.paths, queued.settings, queued.generation); - } - } -} - + } + }); + activeSettingsSave = operation; + await operation; +} + export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise { + const captured = captureSettings(settings); + if (activePersistenceBarrier) { + return blockSettingsSave(activePersistenceBarrier, paths, captured); + } const generation = syncSettingsSaveGeneration; - await saveSettingsPayloadAsync(paths, captureSettings(settings), generation); + await saveSettingsPayloadAsync(paths, captured, generation); } export function emptySession(): SessionState { @@ -1503,61 +1532,177 @@ export function saveSession(paths: StoragePaths, session: SessionState): void { } } -let asyncSaveRunning = false; -let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null; -let syncSaveGeneration = 0; +let asyncSaveRunning = false; +let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null; +let syncSaveGeneration = 0; +let activeSessionSave: Promise | null = null; -async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise { - await fs.promises.mkdir(paths.baseDir, { recursive: true }); - await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); - const tempPath = sessionTempPath(paths.sessionFile, "async"); - const handle = await fsp.open(tempPath, "w"); +async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise { + await fs.promises.mkdir(paths.baseDir, { recursive: true }); + const tempPath = sessionTempPath(paths.sessionFile, "async"); + const backupTempPath = `${paths.sessionFile}.async.backup.tmp`; + const handle = await fsp.open(tempPath, "w"); try { await handle.writeFile(payload, "utf8"); await handle.sync(); } finally { await handle.close(); } - if (generation < syncSaveGeneration) { - await fsp.rm(tempPath, { force: true }).catch(() => {}); - return; - } - try { - await fsp.rename(tempPath, paths.sessionFile); - } catch (renameError: unknown) { - if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { - if (generation < syncSaveGeneration) { - await fsp.rm(tempPath, { force: true }).catch(() => {}); - return; - } - await fsp.copyFile(tempPath, paths.sessionFile); - await fsp.rm(tempPath, { force: true }).catch(() => {}); - } else { - await fsp.rm(tempPath, { force: true }).catch(() => {}); - throw renameError; - } - } -} + if (generation < syncSaveGeneration) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + return; + } + if (fs.existsSync(paths.sessionFile)) { + try { + fs.copyFileSync(paths.sessionFile, backupTempPath); + syncRenameWithExdevFallback(backupTempPath, sessionBackupPath(paths.sessionFile)); + } catch { + try { fs.rmSync(backupTempPath, { force: true }); } catch { } + } + } + try { + syncRenameWithExdevFallback(tempPath, paths.sessionFile); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} -async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise { - if (asyncSaveRunning) { - asyncSaveQueued = { paths, payload, generation }; - return; - } - asyncSaveRunning = true; - try { - await writeSessionPayload(paths, payload, generation); - } catch (error) { - logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); - } finally { - asyncSaveRunning = false; - if (asyncSaveQueued) { - const queued = asyncSaveQueued; - asyncSaveQueued = null; - void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation); - } - } -} +async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise { + if (asyncSaveRunning) { + asyncSaveQueued = { paths, payload, generation }; + return; + } + asyncSaveRunning = true; + const operation = writeSessionPayload(paths, payload, generation).catch((error) => { + logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); + }).finally(() => { + asyncSaveRunning = false; + if (activeSessionSave === operation) { + activeSessionSave = null; + } + if (asyncSaveQueued) { + const queued = asyncSaveQueued; + asyncSaveQueued = null; + void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation); + } + }); + activeSessionSave = operation; + await operation; +} + +interface BlockedSettingsSave { + paths: StoragePaths; + settings: AppSettings; + waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>; +} + +interface BlockedSessionSave { + paths: StoragePaths; + payload: string; + waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>; +} + +interface PersistenceBarrierState { + blockedSettings: BlockedSettingsSave | null; + blockedSession: BlockedSessionSave | null; + released: Promise; + resolveReleased: () => void; +} + +export interface PersistenceBarrier { + release: (options: { replayBlocked: boolean }) => Promise; +} + +let activePersistenceBarrier: PersistenceBarrierState | null = null; + +function blockSettingsSave(state: PersistenceBarrierState, paths: StoragePaths, settings: AppSettings): Promise { + return new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + if (state.blockedSettings) { + state.blockedSettings.paths = paths; + state.blockedSettings.settings = settings; + state.blockedSettings.waiters.push(waiter); + } else { + state.blockedSettings = { paths, settings, waiters: [waiter] }; + } + }); +} + +function blockSessionSave(state: PersistenceBarrierState, paths: StoragePaths, payload: string): Promise { + return new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + if (state.blockedSession) { + state.blockedSession.paths = paths; + state.blockedSession.payload = payload; + state.blockedSession.waiters.push(waiter); + } else { + state.blockedSession = { paths, payload, waiters: [waiter] }; + } + }); +} + +async function replayBlockedSaves(state: PersistenceBarrierState): Promise { + while (state.blockedSettings || state.blockedSession) { + const blockedSettings = state.blockedSettings; + const blockedSession = state.blockedSession; + state.blockedSettings = null; + state.blockedSession = null; + try { + await Promise.all([ + blockedSettings + ? saveSettingsPayloadAsync(blockedSettings.paths, blockedSettings.settings, syncSettingsSaveGeneration) + : Promise.resolve(), + blockedSession + ? saveSessionPayloadAsync(blockedSession.paths, blockedSession.payload, syncSaveGeneration) + : Promise.resolve() + ]); + blockedSettings?.waiters.forEach((waiter) => waiter.resolve()); + blockedSession?.waiters.forEach((waiter) => waiter.resolve()); + } catch (error) { + blockedSettings?.waiters.forEach((waiter) => waiter.reject(error)); + blockedSession?.waiters.forEach((waiter) => waiter.reject(error)); + throw error; + } + } +} + +export async function acquirePersistenceBarrier(): Promise { + while (activePersistenceBarrier) { + await activePersistenceBarrier.released; + } + let resolveReleased = () => {}; + const released = new Promise((resolve) => { resolveReleased = resolve; }); + const state: PersistenceBarrierState = { + blockedSettings: null, + blockedSession: null, + released, + resolveReleased + }; + activePersistenceBarrier = state; + cancelPendingAsyncSaves(); + await Promise.all([activeSettingsSave ?? Promise.resolve(), activeSessionSave ?? Promise.resolve()]); + let finished = false; + return { + release: async ({ replayBlocked }) => { + if (finished) return; + finished = true; + try { + if (replayBlocked) { + await replayBlockedSaves(state); + } else { + state.blockedSettings?.waiters.forEach((waiter) => waiter.resolve()); + state.blockedSession?.waiters.forEach((waiter) => waiter.resolve()); + } + } finally { + if (activePersistenceBarrier === state) { + activePersistenceBarrier = null; + } + state.resolveReleased(); + } + } + }; +} export function cancelPendingAsyncSaves(): void { asyncSaveQueued = null; @@ -1566,11 +1711,14 @@ export function cancelPendingAsyncSaves(): void { syncSettingsSaveGeneration += 1; } -export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { - const generation = syncSaveGeneration; - const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); - await saveSessionPayloadAsync(paths, payload, generation); -} +export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); + if (activePersistenceBarrier) { + return blockSessionSave(activePersistenceBarrier, paths, payload); + } + const generation = syncSaveGeneration; + await saveSessionPayloadAsync(paths, payload, generation); +} const MAX_HISTORY_ENTRIES = 500; const HISTORY_HARD_CAP = 100000; @@ -1647,42 +1795,106 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry }; } -export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] { - ensureBaseDir(paths.baseDir); - if (!fs.existsSync(paths.historyFile)) { - return []; - } - - try { - const raw = JSON.parse(fs.readFileSync(paths.historyFile, "utf8")) as unknown; - if (!Array.isArray(raw)) return []; - - const entries: HistoryEntry[] = []; - for (let i = 0; i < raw.length && entries.length < HISTORY_HARD_CAP; i++) { - const normalized = normalizeHistoryEntry(raw[i], i); - if (normalized) entries.push(normalized); - } - return pruneHistoryEntries(entries, limits); - } catch { - return []; - } -} - -export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void { - ensureBaseDir(paths.baseDir); - const trimmed = pruneHistoryEntries(entries, limits); - const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); - const tempPath = `${paths.historyFile}.tmp`; - try { - fs.writeFileSync(tempPath, payload, "utf8"); - syncRenameWithExdevFallback(tempPath, paths.historyFile); - } catch (error) { - try { fs.rmSync(tempPath, { force: true }); } catch { } - throw error; - } -} - -export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { +function readHistoryFile(filePath: string): HistoryEntry[] | null { + try { + const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + if (!Array.isArray(raw)) return null; + + const entries: HistoryEntry[] = []; + for (let i = 0; i < raw.length && entries.length < HISTORY_HARD_CAP; i++) { + const normalized = normalizeHistoryEntry(raw[i], i); + if (normalized) entries.push(normalized); + } + return entries; + } catch { + return null; + } +} + +function writeHistoryPayload(filePath: string, payload: string): void { + const tempPath = `${filePath}.tmp`; + try { + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, filePath); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} + +export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] { + ensureBaseDir(paths.baseDir); + const backupFile = `${paths.historyFile}.bak`; + const primaryExists = fs.existsSync(paths.historyFile); + const backupExists = fs.existsSync(backupFile); + const primary = primaryExists ? readHistoryFile(paths.historyFile) : null; + if (primary) return pruneHistoryEntries(primary, limits); + const backup = backupExists ? readHistoryFile(backupFile) : null; + if (backup) { + try { + writeHistoryPayload(paths.historyFile, JSON.stringify(backup, safeJsonReplacer, 2)); + } catch (error) { + logger.warn(`Verlauf konnte aus dem Backup gelesen, aber nicht repariert werden: ${String(error)}`); + } + return pruneHistoryEntries(backup, limits); + } + if (primaryExists || backupExists) { + throw new Error("Verlaufsspeicher ist beschädigt"); + } + return []; +} + +export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void { + ensureBaseDir(paths.baseDir); + const trimmed = pruneHistoryEntries(entries, limits); + const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); + const previous = fs.existsSync(paths.historyFile) ? readHistoryFile(paths.historyFile) : null; + if (previous) { + writeHistoryPayload(`${paths.historyFile}.bak`, JSON.stringify(previous, safeJsonReplacer, 2)); + } + writeHistoryPayload(paths.historyFile, payload); +} + +function restoreHistoryPayload(filePath: string, payload: string | null): void { + if (payload === null) { + fs.rmSync(`${filePath}.tmp`, { force: true }); + fs.rmSync(filePath, { force: true }); + return; + } + writeHistoryPayload(filePath, payload); +} + +export function createHistoryRollback(paths: StoragePaths): () => void { + ensureBaseDir(paths.baseDir); + const backupFile = `${paths.historyFile}.bak`; + const previousPrimary = fs.existsSync(paths.historyFile) ? fs.readFileSync(paths.historyFile, "utf8") : null; + const previousBackup = fs.existsSync(backupFile) ? fs.readFileSync(backupFile, "utf8") : null; + return () => { + restoreHistoryPayload(backupFile, previousBackup); + restoreHistoryPayload(paths.historyFile, previousPrimary); + }; +} + +export function replaceHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): () => void { + ensureBaseDir(paths.baseDir); + const payload = JSON.stringify(pruneHistoryEntries(entries, limits), safeJsonReplacer, 2); + const backupFile = `${paths.historyFile}.bak`; + const rollback = createHistoryRollback(paths); + writeHistoryPayload(backupFile, payload); + try { + writeHistoryPayload(paths.historyFile, payload); + } catch (error) { + try { + rollback(); + } catch (rollbackError) { + logger.error(`Verlauf-Backup konnte nach fehlgeschlagener Ersetzung nicht zurückgerollt werden: ${String(rollbackError)}`); + } + throw error; + } + return rollback; +} + +export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { const existing = loadHistory(paths, limits); const updated = pruneHistoryEntries([entry, ...existing], limits); saveHistory(paths, updated, limits); @@ -1724,12 +1936,9 @@ export function removeHistoryEntry(paths: StoragePaths, entryId: string, limits? return removeHistoryEntries(paths, [entryId], limits); } -export function clearHistory(paths: StoragePaths): void { - ensureBaseDir(paths.baseDir); - if (fs.existsSync(paths.historyFile)) { - try { - fs.unlinkSync(paths.historyFile); - } catch { - } - } -} +export function clearHistory(paths: StoragePaths): void { + ensureBaseDir(paths.baseDir); + for (const filePath of [`${paths.historyFile}.bak.tmp`, `${paths.historyFile}.tmp`, `${paths.historyFile}.bak`, paths.historyFile]) { + fs.rmSync(filePath, { force: true }); + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d3ada9b..7f37db4 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -70,8 +70,9 @@ const api: ElectronApi = { return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); }, getCollectorState: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_COLLECTOR_STATE), + getCollectorStateSync: (): CollectorPersistenceState | null => ipcRenderer.sendSync(IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC) as CollectorPersistenceState | null, saveCollectorState: (state: CollectorPersistenceState): Promise => ipcRenderer.invoke(IPC_CHANNELS.SAVE_COLLECTOR_STATE, state), - saveCollectorStateSync: (state: CollectorPersistenceState): void => { ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); }, + saveCollectorStateSync: (state: CollectorPersistenceState): boolean => ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state) === true, getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 45bbd8a..ee35de9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -63,18 +63,22 @@ import { Toast } from "./ui/Toast"; import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; import { serializeCollectorPackages, type CollectorEnrichmentProgress, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector"; import { routeDroppedDlcFiles } from "./collector-drop"; -import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment"; +import { beginCollectorEnrichment, filterCurrentCollectorEnrichment, pruneCollectorEnrichmentGenerations } from "./collector-enrichment"; import { buildCollectorTransferPackages, buildCollectorWorkspaceViewModel, - mergeCollectorEnrichment, - mergeCollectorPackages, + mergeCollectorPackagesWithinCapacity, removeCollectorLinks, reconcileCollectorCollapsedPackageIds, + reconcileCollectorCollapsedPackageIdsWithPackages, + reconcileCollectorSelectionWithPackages, + setCollectorVisibleSelection, selectCollectorPackageLinks, type CollectorWorkspaceFilter } from "./views/collector/collector-model"; -import { createCollectorPersistenceCoordinator, restoreCollectorPersistenceState, type CollectorPersistenceCoordinator } from "./views/collector/collector-persistence"; +import { guardCollectorBeforeUnload, resolveCollectorLateHydration } from "./views/collector/collector-hydration"; +import { createCollectorPersistenceCoordinator, type CollectorPersistenceCoordinator } from "./views/collector/collector-persistence"; +import { advanceCollectorPersistenceBudget, createCollectorPersistenceBudget, type CollectorPersistenceBudget } from "./views/collector/collector-persistence-budget"; import { CollectorInputDialog, MemoizedCollectorContent, @@ -87,7 +91,7 @@ import { buildHistoryViewModel, mergeLiveHistoryEntry, pruneHistoryIds, - selectVisibleHistoryIds, + toggleHistoryPageSelection, type HistoryFilter } from "./views/history/history-model"; import { @@ -107,16 +111,20 @@ import { StatisticsSidebarStatus, type StatisticsViewActions } from "./views/statistics/StatisticsView"; -import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { buildDownloadsViewModel, formatDownloadEta, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; import { + DownloadContextStartActions, DownloadsContent, DownloadsFooter, DownloadsSidebar, DownloadsSidebarStatus, DownloadsToolbar, + runPauseDownloadAction, + runResumeDownloadAction, + runDownloadStartAction, type DailyScheduleStartDay, type DownloadsViewActions, type DownloadsViewModel @@ -134,7 +142,8 @@ import { type AccountRowSource, type SettingsFormViewModel, type SettingsSaveState, - type SettingsSection + type SettingsSection, + type SettingsThemeChoice } from "./views/settings/settings-model"; import { AccountAddDialog, @@ -152,6 +161,121 @@ import { type Tab = MainView; +const appIntegerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); + +export function formatHistorySidebarCounts(entries: number, visible: number, selected: number): { + entries: string; + visible: string; + selected: string; +} { + return { + entries: `Einträge: ${appIntegerFormatter.format(entries)}`, + visible: `Sichtbar: ${appIntegerFormatter.format(visible)}`, + selected: `Ausgewählt: ${appIntegerFormatter.format(selected)}` + }; +} + +function mergeSnapshotWireState(master: UiSnapshot, wireState: UiSnapshot): UiSnapshot { + if (wireState.payloadKind !== "delta") return wireState; + const items: Record = { ...master.session.items, ...wireState.session.items }; + for (const id of wireState.removedItemIds ?? []) delete items[id]; + const packages: Record = { ...master.session.packages, ...wireState.session.packages }; + for (const id of wireState.removedPackageIds ?? []) delete packages[id]; + return { + ...wireState, + session: { + ...wireState.session, + items, + packages + } + }; +} + +function getSnapshotRevision(snapshot: UiSnapshot): number | null { + const revision = snapshot.snapshotRevision; + return typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0 + ? revision + : null; +} + +function mergeNewerSnapshot(master: UiSnapshot, incoming: UiSnapshot): UiSnapshot | null { + const masterRevision = getSnapshotRevision(master); + const incomingRevision = getSnapshotRevision(incoming); + if (masterRevision !== null && (incomingRevision === null || incomingRevision <= masterRevision)) { + return null; + } + return mergeSnapshotWireState(master, incoming); +} + +export interface SnapshotBootstrapCoordinator { + initialize: (snapshot: UiSnapshot) => UiSnapshot; + push: (snapshot: UiSnapshot) => UiSnapshot | null; + getStatus: () => SnapshotBootstrapStatus; + fail: (message: string) => SnapshotBootstrapStatus; + retry: () => SnapshotBootstrapStatus; +} + +export type SnapshotBootstrapStatus = { + phase: "loading" | "ready" | "error"; + message: string; +}; + +export function createSnapshotBootstrapCoordinator(): SnapshotBootstrapCoordinator { + let initialized = false; + let current: UiSnapshot | null = null; + let pending: UiSnapshot[] = []; + let status: SnapshotBootstrapStatus = { phase: "loading", message: "" }; + return { + initialize: (snapshot) => { + if (initialized && current) { + const currentRevision = getSnapshotRevision(current); + const snapshotRevision = getSnapshotRevision(snapshot); + if (snapshotRevision !== null && (currentRevision === null || snapshotRevision > currentRevision)) { + current = snapshot; + } + return current; + } + current = pending.reduce((master, incoming) => mergeNewerSnapshot(master, incoming) ?? master, snapshot); + pending = []; + initialized = true; + status = { phase: "ready", message: "" }; + return current; + }, + push: (snapshot) => { + if (!initialized) { + if (snapshot.payloadKind === "delta") { + pending.push(snapshot); + return null; + } + current = snapshot; + pending = []; + initialized = true; + status = { phase: "ready", message: "" }; + return current; + } + const merged = mergeNewerSnapshot(current as UiSnapshot, snapshot); + if (!merged) { + return null; + } + current = merged; + return current; + }, + getStatus: () => ({ ...status }), + fail: (message) => { + if (!initialized) { + status = { phase: "error", message }; + } + return { ...status }; + }, + retry: () => { + if (!initialized) { + status = { phase: "loading", message: "" }; + } + return { ...status }; + } + }; +} + interface CollectorInputState { draft: string; } @@ -289,8 +413,6 @@ function getAccountToggleTarget(account: AccountTableRow): AccountToggleTarget { return { type: "provider", provider: account.entry.provider }; } -type SettingsThemeChoice = AppTheme | "system"; - interface RendererSettingsDraft extends RendererSettings { archivePasswordList: string; notifyUrl: string; @@ -304,7 +426,7 @@ export function createSettingsDraft(settings: RendererSettings, current?: Render }; } -export function createDiscardedSettingsState(settings: RendererSettings, themeChoice: SettingsThemeChoice = settings.theme): { +export function createDiscardedSettingsState(settings: RendererSettings, themeChoice: SettingsThemeChoice = settings.themePreference): { draft: RendererSettingsDraft; themeChoice: SettingsThemeChoice; speedLimitInput: string; @@ -331,6 +453,10 @@ export function resolveSettingsSaveCompletion(revisionAtStart: number, currentRe : { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert" }; } +export function captureSettingsSaveIntent(revision: number, draft: T): { revision: number; draft: T } { + return { revision, draft: structuredClone(draft) }; +} + export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme { return choice === "system" ? (prefersLight ? "light" : "dark") : choice; } @@ -828,7 +954,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, + theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50, notifyOnDownloadStall: false, notifyStallAfterSeconds: 90, notifyStallCooldownMinutes: 10, notifyOnDownloadRecovery: true, @@ -869,7 +995,7 @@ const emptySnapshot = (): UiSnapshot => ({ paused: false, running: false, updatedAt: Date.now() }, summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --", - canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {} + canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}, runRemainingBytes: 0, runRemainingUnknownItems: 0 }); const cleanupLabels: Record = { @@ -1662,16 +1788,37 @@ export async function persistDailyScheduleSettingsUpdate( } } +export function routeHistoryLiveEntry( + entry: HistoryEntry, + loadFailed: boolean, + actions: { reload: () => void; accept: (entry: HistoryEntry) => void } +): void { + if (loadFailed) { + actions.reload(); + return; + } + actions.accept(entry); +} + +export function selectHistoryPageFromShortcut(current: ReadonlySet, visibleIds: readonly string[]): Set { + return toggleHistoryPageSelection(current, visibleIds); +} + +export function resolveHistoryContextSelection(current: ReadonlySet, entryId: string): Set { + return current.has(entryId) ? new Set(current) : new Set([entryId]); +} + export function App(): ReactElement { - const [snapshot, setSnapshot] = useState(emptySnapshot); - const [appVersion, setAppVersion] = useState(""); + const [snapshot, setSnapshot] = useState(emptySnapshot); + const [snapshotBootstrapStatus, setSnapshotBootstrapStatus] = useState({ phase: "loading", message: "" }); + const [appVersion, setAppVersion] = useState(""); const [tab, setTab] = useState("downloads"); const [statusToast, setStatusToast] = useState(""); const [availableUpdate, setAvailableUpdate] = useState(null); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(() => createSettingsDraft(emptySnapshot().settings)); - const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); + const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.themePreference); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); @@ -1698,12 +1845,26 @@ export function App(): ReactElement { const latestStateRef = useRef(null); const masterSnapshotRef = useRef(null); const snapshotRef = useRef(snapshot); + const retrySnapshotBootstrapRef = useRef<() => void>(() => {}); const persistedSettingsRef = useRef(emptySnapshot().settings); const settingsThemeChoiceRef = useRef(settingsThemeChoice); - const persistedThemeChoiceRef = useRef(emptySnapshot().settings.theme); + const persistedThemeChoiceRef = useRef(emptySnapshot().settings.themePreference); snapshotRef.current = snapshot; settingsThemeChoiceRef.current = settingsThemeChoice; - const tabRef = useRef(tab); + useEffect(() => { + const systemTheme = window.matchMedia("(prefers-color-scheme: light)"); + const onSystemThemeChange = (): void => { + if (settingsThemeChoiceRef.current !== "system") { + return; + } + const next = resolveSettingsThemeChoice("system", systemTheme.matches); + setSettingsDraft((current) => ({ ...current, theme: next })); + applyTheme(next); + }; + systemTheme.addEventListener("change", onSystemThemeChange); + return () => systemTheme.removeEventListener("change", onSystemThemeChange); + }, []); + const tabRef = useRef(tab); tabRef.current = tab; const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); @@ -1723,8 +1884,10 @@ export function App(): ReactElement { const [collectorInput, setCollectorInput] = useState(null); const collectorPackagesRef = useRef(collectorPackages); const collapsedCollectorPackageIdsRef = useRef>(collapsedCollectorPackageIds); + const collectorVisibleIdsRef = useRef([]); const collectorPersistenceHydratedRef = useRef(false); const collectorPersistenceCoordinatorRef = useRef(null); + const collectorPersistenceBudgetRef = useRef(null); const collectorEnrichmentGenerationsRef = useRef(new Map()); const collectorEnrichmentRequestsRef = useRef(new Map>()); const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); @@ -1829,6 +1992,7 @@ export function App(): ReactElement { const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [historyQuery, setHistoryQuery] = useState(""); + const [historyVisiblePageCount, setHistoryVisiblePageCount] = useState(0); const [statisticsRange, setStatisticsRange] = useState("session"); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(""); @@ -1836,6 +2000,7 @@ export function App(): ReactElement { const historyCtxMenuRef = useRef(null); const historyLoadGenerationRef = useRef(0); const historyLoadActiveRef = useRef(false); + const historyLoadFailedRef = useRef(false); const pendingLiveHistoryEntriesRef = useRef([]); const historyVisibleIdsRef = useRef([]); const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); @@ -1857,6 +2022,11 @@ export function App(): ReactElement { collectorError, snapshot.settings.animatePackageDisclosure ), [collapsedCollectorPackageIds, collectorAnalyzingCount, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]); + collectorVisibleIdsRef.current = collectorViewModel.visibleIds; + + const historyCalendarDate = new Date(runtimeNow); + historyCalendarDate.setHours(0, 0, 0, 0); + const historyCalendarDayStart = historyCalendarDate.getTime(); const historyViewModel = useMemo(() => buildHistoryViewModel( historyEntries, @@ -1866,10 +2036,15 @@ export function App(): ReactElement { historyExpandedIds, historyLoading, historyError, - runtimeNow, - snapshot.settings.animatePackageDisclosure - ), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds, snapshot.settings.animatePackageDisclosure]); - historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id); + historyCalendarDayStart, + snapshot.settings.animatePackageDisclosure, + normalizeLanguage(settingsDraft.language) + ), [historyCalendarDayStart, historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, selectedHistoryIds, settingsDraft.language, snapshot.settings.animatePackageDisclosure]); + const historySidebarCounts = formatHistorySidebarCounts( + historyViewModel.totalCount, + historyViewModel.loading || historyViewModel.error ? 0 : historyVisiblePageCount, + historyViewModel.selectedIds.length + ); const statisticsViewModel = useMemo( () => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow), [runtimeNow, snapshot, statisticsRange] @@ -1986,6 +2161,7 @@ export function App(): ReactElement { const loadHistoryEntries = useCallback(async (): Promise => { const generation = ++historyLoadGenerationRef.current; historyLoadActiveRef.current = true; + historyLoadFailedRef.current = false; pendingLiveHistoryEntriesRef.current = []; setHistoryLoading(true); setHistoryError(""); @@ -2006,6 +2182,9 @@ export function App(): ReactElement { applyHistoryEntries(merged); } catch { if (mountedRef.current && generation === historyLoadGenerationRef.current) { + historyLoadFailedRef.current = true; + pendingLiveHistoryEntriesRef.current = []; + applyHistoryEntries([]); setHistoryError("Verlauf konnte nicht geladen werden"); } } finally { @@ -2034,21 +2213,26 @@ export function App(): ReactElement { if (activeTabRef.current !== "history") { return; } - if (historyLoadActiveRef.current) { - pendingLiveHistoryEntriesRef.current = [ - entry, - ...pendingLiveHistoryEntriesRef.current.filter((pending) => pending.id !== entry.id) - ]; - } - const settings = snapshotRef.current.settings; - applyHistoryEntries(mergeLiveHistoryEntry(historyEntriesRef.current, entry, { - maxEntries: settings.historyMaxEntries, - maxAgeDays: settings.historyMaxAgeDays - })); - setHistoryError(""); + routeHistoryLiveEntry(entry, historyLoadFailedRef.current, { + reload: () => { void loadHistoryEntries(); }, + accept: (liveEntry) => { + if (historyLoadActiveRef.current) { + pendingLiveHistoryEntriesRef.current = [ + liveEntry, + ...pendingLiveHistoryEntriesRef.current.filter((pending) => pending.id !== liveEntry.id) + ]; + } + const settings = snapshotRef.current.settings; + applyHistoryEntries(mergeLiveHistoryEntry(historyEntriesRef.current, liveEntry, { + maxEntries: settings.historyMaxEntries, + maxAgeDays: settings.historyMaxAgeDays + })); + setHistoryError(""); + } + }); }); return unsubscribeHistoryEntryAdded; - }, [applyHistoryEntries]); + }, [applyHistoryEntries, loadHistoryEntries]); const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { const requestId = allDebridHostRequestRef.current + 1; @@ -2135,87 +2319,108 @@ export function App(): ReactElement { useEffect(() => { mountedRef.current = true; let unsubscribe: (() => void) | null = null; - let unsubClipboard: (() => void) | null = null; - let unsubUpdateInstallProgress: (() => void) | null = null; - void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); - void window.rd.getTraceConfig().then((config) => { - if (mountedRef.current) { - setSupportTraceEnabled(config.enabled); - } - }).catch(() => undefined); - void window.rd.getSnapshot().then((state) => { - if (!mountedRef.current) { - return; - } + let unsubClipboard: (() => void) | null = null; + let unsubUpdateInstallProgress: (() => void) | null = null; + let snapshotRequestGeneration = 0; + let authoritativeSnapshotApplied = false; + const snapshotCoordinator = createSnapshotBootstrapCoordinator(); + const applyStreamSnapshot = (merged: UiSnapshot): void => { + masterSnapshotRef.current = merged; + persistedSettingsRef.current = merged.settings; + latestStateRef.current = merged; + if (stateFlushTimerRef.current) return; + const itemCount = Object.keys(merged.session.items).length; + const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current); + stateFlushTimerRef.current = setTimeout(() => { + stateFlushTimerRef.current = null; + if (!latestStateRef.current) return; + const next = latestStateRef.current; + setSnapshot(next); + if (!settingsDirtyRef.current) { + setSettingsDraft((current) => createSettingsDraft(next.settings, current)); + } + latestStateRef.current = null; + }, flushDelay); + }; + const applyAuthoritativeSnapshot = ( + state: UiSnapshot, + storedThemeChoice: SettingsThemeChoice = state.settings.themePreference + ): void => { + if (authoritativeSnapshotApplied) { + return; + } + authoritativeSnapshotApplied = true; masterSnapshotRef.current = state; persistedSettingsRef.current = state.settings; - persistedThemeChoiceRef.current = state.settings.theme; + const resolvedTheme = resolveSettingsThemeChoice( + storedThemeChoice, + window.matchMedia("(prefers-color-scheme: light)").matches + ); + persistedThemeChoiceRef.current = storedThemeChoice; setSnapshot(state); + setSnapshotBootstrapStatus(snapshotCoordinator.getStatus()); if (state.settings.columnOrder?.length > 0) { columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder); - } - setSettingsDraft((current) => createSettingsDraft(state.settings, current)); + } + setSettingsDraft((current) => createSettingsDraft({ ...state.settings, theme: resolvedTheme }, current)); writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); setSettingsSaveState("clean"); - setSettingsThemeChoice(state.settings.theme); - settingsThemeChoiceRef.current = state.settings.theme; - applyTheme(state.settings.theme); + setSettingsThemeChoice(storedThemeChoice); + settingsThemeChoiceRef.current = storedThemeChoice; + applyTheme(resolvedTheme); if (state.settings.autoUpdateCheck) { void runLatestUpdateCheck( updateCheckGenerationRef, () => window.rd.checkUpdates(), (result, generation) => handleUpdateResult(result, "startup", generation) ).catch(() => undefined); - } - }).catch((error) => { - showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); - }); - unsubscribe = window.rd.onStateUpdate((wireState) => { - let merged: UiSnapshot; - const master = masterSnapshotRef.current; - if (wireState.payloadKind === "delta" && master) { - const newItems: Record = { ...master.session.items, ...wireState.session.items }; - if (wireState.removedItemIds && wireState.removedItemIds.length > 0) { - for (const id of wireState.removedItemIds) delete newItems[id]; - } - const newPackages: Record = { ...master.session.packages, ...wireState.session.packages }; - if (wireState.removedPackageIds && wireState.removedPackageIds.length > 0) { - for (const id of wireState.removedPackageIds) delete newPackages[id]; - } - merged = { - ...wireState, - session: { - ...wireState.session, - items: newItems, - packages: newPackages, - }, - }; - } else { - merged = wireState; - } - masterSnapshotRef.current = merged; - persistedSettingsRef.current = merged.settings; - latestStateRef.current = merged; - if (stateFlushTimerRef.current) { return; } - - const itemCount = Object.keys(merged.session.items).length; - const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current); - - stateFlushTimerRef.current = setTimeout(() => { - stateFlushTimerRef.current = null; - if (latestStateRef.current) { - const next = latestStateRef.current; - setSnapshot(next); - if (!settingsDirtyRef.current) { - setSettingsDraft((current) => createSettingsDraft(next.settings, current)); - } - latestStateRef.current = null; - } - }, flushDelay); + } + }; + unsubscribe = window.rd.onStateUpdate((wireState) => { + const merged = snapshotCoordinator.push(wireState); + if (!merged) { + return; + } + if (!authoritativeSnapshotApplied && snapshotCoordinator.getStatus().phase === "ready") { + applyAuthoritativeSnapshot(merged); + return; + } + applyStreamSnapshot(merged); }); + void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); + void window.rd.getTraceConfig().then((config) => { + if (mountedRef.current) { + setSupportTraceEnabled(config.enabled); + } + }).catch(() => undefined); + const loadInitialSnapshot = (): void => { + const generation = ++snapshotRequestGeneration; + setSnapshotBootstrapStatus(snapshotCoordinator.retry()); + void window.rd.getSnapshot().then((initialState) => { + if (!mountedRef.current || generation !== snapshotRequestGeneration) { + return; + } + const state = snapshotCoordinator.initialize(initialState); + if (authoritativeSnapshotApplied) { + if (state !== masterSnapshotRef.current) { + applyStreamSnapshot(state); + } + } else { + applyAuthoritativeSnapshot(state, state.settings.themePreference); + } + }).catch((error) => { + if (!mountedRef.current || generation !== snapshotRequestGeneration) { + return; + } + const message = `Snapshot konnte nicht geladen werden: ${String(error)}`; + setSnapshotBootstrapStatus(snapshotCoordinator.fail(message)); + }); + }; + retrySnapshotBootstrapRef.current = loadInitialSnapshot; + loadInitialSnapshot(); unsubClipboard = window.rd.onClipboardDetected((links) => { showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); void importCollectorTextRef.current(links.join("\n")); @@ -2253,8 +2458,9 @@ export function App(): ReactElement { } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } - if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); } - }; + if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); } + retrySnapshotBootstrapRef.current = () => {}; + }; }, [clearImportQueueFocusListener]); const downloadsTabActive = tab === "downloads"; @@ -2338,7 +2544,13 @@ export function App(): ReactElement { showAllPackages: showAllPackages || !snapshot.session.running, renderLimit: AUTO_RENDER_PACKAGE_LIMIT }), [collapsedPackages, deferredDownloadSearch, downloadDisplayMode, downloadFilter, downloadProviderFilter, selectedIds, showAllPackages, snapshot.session.items, snapshot.session.packages, snapshot.session.running, snapshot.settings.hideExtractedItems, visiblePackages]); - + + useEffect(() => { + if (downloadsTabActive && downloadProviderFilter !== downloadsViewCore.providerFilter) { + setDownloadProviderFilter(downloadsViewCore.providerFilter); + } + }, [downloadProviderFilter, downloadsTabActive, downloadsViewCore.providerFilter]); + const hasSavedAllDebridAccount = snapshot.accounts.some((account) => account.provider === "alldebrid"); const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin; @@ -2841,15 +3053,20 @@ export function App(): ReactElement { } const revisionAtStart = settingsDraftRevisionRef.current; const themeChoiceAtStart = settingsThemeChoiceRef.current; + const saveIntent = captureSettingsSaveIntent(revisionAtStart, normalizedSettingsDraft); + const writeOnlyFieldsAtStart = new Set(writeOnlySettingsDirtyRef.current); settingsSaveInFlightRef.current = true; setSettingsSaveInFlight(true); setSettingsSaveState("saving"); try { await performQuickAction(async () => { - const result = await persistDraftSettings(themeChoiceAtStart); + const result = await persistDraftSettings(themeChoiceAtStart, saveIntent, writeOnlyFieldsAtStart); const completion = resolveSettingsSaveCompletion(revisionAtStart, settingsDraftRevisionRef.current); if (completion.applyPersistedTheme) { - applyTheme(result.theme); + applyTheme(resolveSettingsThemeChoice( + themeChoiceAtStart, + window.matchMedia("(prefers-color-scheme: light)").matches + )); } setSettingsSaveState(completion.saveState); showToast(completion.toast, 1800); @@ -2880,7 +3097,10 @@ export function App(): ReactElement { settingsThemeChoiceRef.current = restored.themeChoice; setSpeedLimitInput(restored.speedLimitInput); setScheduleSpeedInputs(restored.scheduleSpeedInputs); - applyTheme(restored.draft.theme); + applyTheme(resolveSettingsThemeChoice( + restored.themeChoice, + window.matchMedia("(prefers-color-scheme: light)").matches + )); showToast("Ungespeicherte Änderungen verworfen", 1800); if (settingsSubTab === "extract") { const generation = archivePasswordLoadGenerationRef.current; @@ -2930,7 +3150,7 @@ export function App(): ReactElement { const applyPersistedSettings = ( result: RendererSettings, preserveWriteOnlyValues = true, - themeChoice: SettingsThemeChoice = settingsThemeChoiceRef.current === "system" ? "system" : result.theme + themeChoice: SettingsThemeChoice = result.themePreference ): void => { persistedSettingsRef.current = result; persistedThemeChoiceRef.current = themeChoice; @@ -2948,7 +3168,10 @@ export function App(): ReactElement { setSettingsSaveState("clean"); setSettingsThemeChoice(themeChoice); settingsThemeChoiceRef.current = themeChoice; - applyTheme(result.theme); + applyTheme(resolveSettingsThemeChoice( + themeChoice, + window.matchMedia("(prefers-color-scheme: light)").matches + )); }; const syncLiveProviderUsageSettings = (result: RendererSettings): void => { @@ -3325,23 +3548,34 @@ export function App(): ReactElement { return result.value; }; - const persistDraftSettingsDirect = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise => { - const revisionAtStart = settingsDraftRevisionRef.current; - const rebasedDraft = mergeAccountToggleSettings(normalizedSettingsDraft, persistedSettingsRef.current); - const update: RendererSettingsUpdate = { ...rebasedDraft }; - if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; - if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; + const persistDraftSettingsDirect = async ( + themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current, + intent = captureSettingsSaveIntent(settingsDraftRevisionRef.current, normalizedSettingsDraft), + writeOnlyFields = new Set(writeOnlySettingsDirtyRef.current) + ): Promise => { + const rebasedDraft = mergeAccountToggleSettings(intent.draft, persistedSettingsRef.current); + const update: RendererSettingsUpdate = { + ...rebasedDraft, + theme: resolveSettingsThemeChoice(themeChoiceAtStart, window.matchMedia("(prefers-color-scheme: light)").matches), + themePreference: themeChoiceAtStart + }; + if (!writeOnlyFields.has("archivePasswordList")) delete update.archivePasswordList; + if (!writeOnlyFields.has("notifyUrl")) delete update.notifyUrl; const result = await window.rd.updateSettings(update); persistedSettingsRef.current = result; - persistedThemeChoiceRef.current = themeChoiceAtStart; - if (settingsDraftRevisionRef.current === revisionAtStart) { - applyPersistedSettings(result, true, themeChoiceAtStart); + persistedThemeChoiceRef.current = result.themePreference; + if (settingsDraftRevisionRef.current === intent.revision) { + applyPersistedSettings(result); } return result; }; - const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise => ( - runQueuedSettingsMutation(() => persistDraftSettingsDirect(themeChoiceAtStart)) + const persistDraftSettings = async ( + themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current, + intent = captureSettingsSaveIntent(settingsDraftRevisionRef.current, normalizedSettingsDraft), + writeOnlyFields = new Set(writeOnlySettingsDirtyRef.current) + ): Promise => ( + runQueuedSettingsMutation(() => persistDraftSettingsDirect(themeChoiceAtStart, intent, writeOnlyFields)) ); const closeStartConflictPrompt = (result: { policy: Extract; applyToAll: boolean } | null): void => { @@ -3510,10 +3744,7 @@ export function App(): ReactElement { }); }, onToggleSelectAll: (visibleIds) => { - setSelectedHistoryIds((current) => { - const allSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id)); - return allSelected ? new Set() : selectVisibleHistoryIds(visibleIds); - }); + setSelectedHistoryIds((current) => toggleHistoryPageSelection(current, visibleIds)); }, onToggleExpansion: (entryId) => { setHistoryExpandedIds((current) => { @@ -3531,11 +3762,12 @@ export function App(): ReactElement { onRemove: (entryIds) => { void removeHistoryEntries(entryIds); }, onClearSelection: () => setSelectedHistoryIds(new Set()), onClearHistory: () => { void clearHistoryEntries(); }, + onVisiblePageChange: (ids) => { + historyVisibleIdsRef.current = ids; + setHistoryVisiblePageCount(ids.length); + }, onContextMenu: (entryId, x, y) => { - setSelectedHistoryIds((current) => { - const visibleSelection = pruneHistoryIds(current, historyVisibleIdsRef.current); - return visibleSelection.has(entryId) ? visibleSelection : new Set([entryId]); - }); + setSelectedHistoryIds((current) => resolveHistoryContextSelection(current, entryId)); setHistoryCtxMenu({ entryId, x, y }); } }), [clearHistoryEntries, removeHistoryEntries, restoreHistoryEntries, revealHistoryEntry]); @@ -3601,20 +3833,88 @@ export function App(): ReactElement { } }; - const mergeCollectorResult = (result: CollectorInspectionResult, enrichment = false): void => { + const mergeCollectorResult = (result: CollectorInspectionResult, enrichment = false): boolean => { const current = collectorPackagesRef.current; - const merged = enrichment - ? mergeCollectorEnrichment(current, result.packages) - : mergeCollectorPackages(current, result.packages); - collectorPackagesRef.current = merged.packages; - setCollectorPackages(merged.packages); - setCollapsedCollectorPackageIds((collapsed) => reconcileCollectorCollapsedPackageIds( - collapsed, + const decision = mergeCollectorPackagesWithinCapacity(current, result.packages, enrichment); + if (!decision.ok) { + setCollectorError(decision.message); + if (!enrichment) showToast(decision.message, 3200); + return false; + } + const merged = decision.value; + const nextCollapsed = reconcileCollectorCollapsedPackageIds( + collapsedCollectorPackageIdsRef.current, current, merged.packages, result.packages, !enrichment - )); + ); + const nextState = { + packages: merged.packages, + collapsedPackageIds: [...nextCollapsed] + }; + const persistenceBudget = enrichment && collectorPersistenceBudgetRef.current + ? advanceCollectorPersistenceBudget( + collectorPersistenceBudgetRef.current, + merged.persistenceDelta, + nextState.collapsedPackageIds + ) + : createCollectorPersistenceBudget(nextState); + if (!persistenceBudget.ok) { + setCollectorError(persistenceBudget.message); + if (!enrichment) showToast(persistenceBudget.message, 3200); + return false; + } + collectorPersistenceBudgetRef.current = persistenceBudget.nextBudget; + collectorPackagesRef.current = merged.packages; + collapsedCollectorPackageIdsRef.current = nextCollapsed; + setCollectorPackages(merged.packages); + setCollapsedCollectorPackageIds(nextCollapsed); + return true; + }; + + const reconcileCollectorDependentState = (packages: CollectorPackage[]): void => { + setSelectedCollectorLinkIds((current) => reconcileCollectorSelectionWithPackages(current, packages)); + const nextCollapsed = reconcileCollectorCollapsedPackageIdsWithPackages(collapsedCollectorPackageIdsRef.current, packages); + collapsedCollectorPackageIdsRef.current = nextCollapsed; + setCollapsedCollectorPackageIds(nextCollapsed); + pruneCollectorEnrichmentGenerations( + collectorEnrichmentGenerationsRef.current, + packages, + collectorEnrichmentRequestsRef.current.values() + ); + const budget = createCollectorPersistenceBudget({ + packages, + collapsedPackageIds: [...nextCollapsed] + }); + collectorPersistenceBudgetRef.current = budget.ok ? budget.nextBudget : null; + }; + + const applyCollectorCollapsedPackageIds = (next: Set): boolean => { + const state = { + packages: collectorPackagesRef.current, + collapsedPackageIds: [...next] + }; + const budget = collectorPersistenceBudgetRef.current + ? advanceCollectorPersistenceBudget( + collectorPersistenceBudgetRef.current, + { + links: [], + packages: [], + removedPackageIds: [], + packageCount: collectorPersistenceBudgetRef.current.packageCount + }, + state.collapsedPackageIds + ) + : createCollectorPersistenceBudget(state); + if (!budget.ok) { + setCollectorError(budget.message); + return false; + } + collectorPersistenceBudgetRef.current = budget.nextBudget; + collapsedCollectorPackageIdsRef.current = next; + setCollapsedCollectorPackageIds(next); + return true; }; const enrichCollectorResult = (packages: CollectorPackage[]): void => { @@ -3640,6 +3940,11 @@ export function App(): ReactElement { } }).finally(() => { collectorEnrichmentRequestsRef.current.delete(requestId); + pruneCollectorEnrichmentGenerations( + collectorEnrichmentGenerationsRef.current, + collectorPackagesRef.current, + collectorEnrichmentRequestsRef.current.values() + ); setCollectorAnalyzingCount((current) => Math.max(0, current - 1)); }); }; @@ -3659,38 +3964,72 @@ export function App(): ReactElement { useEffect(() => { let cancelled = false; - const coordinator = createCollectorPersistenceCoordinator((state) => window.rd.saveCollectorState(state)); + const coordinator = createCollectorPersistenceCoordinator( + (state) => window.rd.saveCollectorState(state), + 300, + (failure) => { + if (cancelled) return; + setCollectorError(`Linksammler konnte nicht gespeichert werden: ${String(failure.error)}`); + if (!failure.rollbackState) return; + collectorPackagesRef.current = failure.rollbackState.packages; + collapsedCollectorPackageIdsRef.current = new Set(failure.rollbackState.collapsedPackageIds); + setCollectorPackages(failure.rollbackState.packages); + setCollapsedCollectorPackageIds(new Set(failure.rollbackState.collapsedPackageIds)); + setSelectedCollectorLinkIds((current) => reconcileCollectorSelectionWithPackages(current, failure.rollbackState?.packages ?? [])); + pruneCollectorEnrichmentGenerations( + collectorEnrichmentGenerationsRef.current, + failure.rollbackState.packages, + collectorEnrichmentRequestsRef.current.values() + ); + const rollbackBudget = createCollectorPersistenceBudget(failure.rollbackState); + collectorPersistenceBudgetRef.current = rollbackBudget.ok ? rollbackBudget.nextBudget : null; + } + ); collectorPersistenceCoordinatorRef.current = coordinator; - const saveBeforeUnload = (): void => { - if (!collectorPersistenceHydratedRef.current) return; - window.rd.saveCollectorStateSync({ - packages: collectorPackagesRef.current, - collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + const saveBeforeUnload = (event: BeforeUnloadEvent): void => { + const saved = guardCollectorBeforeUnload(event, { + hydrated: collectorPersistenceHydratedRef.current, + currentState: { + packages: collectorPackagesRef.current, + collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + }, + getPersistedStateSync: window.rd.getCollectorStateSync, + saveStateSync: window.rd.saveCollectorStateSync }); + if (!saved) setCollectorError("Linksammler konnte nicht gespeichert werden: Speichern fehlgeschlagen"); }; window.addEventListener("beforeunload", saveBeforeUnload); void window.rd.getCollectorState().then((persisted) => { if (cancelled) return; - const restored = restoreCollectorPersistenceState( - persisted, - collectorPackagesRef.current, - collapsedCollectorPackageIdsRef.current - ); + const hydration = resolveCollectorLateHydration(persisted, { + packages: collectorPackagesRef.current, + collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + }); + const restored = hydration.ok ? hydration.state : hydration.rollbackState; collectorPackagesRef.current = restored.packages; collapsedCollectorPackageIdsRef.current = new Set(restored.collapsedPackageIds); setCollectorPackages(restored.packages); setCollapsedCollectorPackageIds(new Set(restored.collapsedPackageIds)); + setSelectedCollectorLinkIds((current) => reconcileCollectorSelectionWithPackages(current, restored.packages)); + pruneCollectorEnrichmentGenerations( + collectorEnrichmentGenerationsRef.current, + restored.packages, + collectorEnrichmentRequestsRef.current.values() + ); + const restoredBudget = createCollectorPersistenceBudget(restored); + collectorPersistenceBudgetRef.current = restoredBudget.ok ? restoredBudget.nextBudget : null; collectorPersistenceHydratedRef.current = true; + coordinator.setBaseline(persisted); + if (!hydration.ok) { + setCollectorError(hydration.message); + if (restored.packages.length > 0) enrichCollectorResult(restored.packages); + return; + } coordinator.schedule(restored); if (restored.packages.length > 0) enrichCollectorResult(restored.packages); }).catch((error) => { if (cancelled) return; - collectorPersistenceHydratedRef.current = true; setCollectorError(`Linksammler konnte nicht wiederhergestellt werden: ${String(error)}`); - coordinator.schedule({ - packages: collectorPackagesRef.current, - collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] - }); }); return () => { cancelled = true; @@ -3731,7 +4070,7 @@ export function App(): ReactElement { showToast(message, 2600); return; } - mergeCollectorResult(prepared); + if (!mergeCollectorResult(prepared)) return; setCollectorFilter("all"); setTab("collector"); const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0); @@ -3762,7 +4101,7 @@ export function App(): ReactElement { showToast(message, 3000); return prepared; } - mergeCollectorResult(prepared); + if (!mergeCollectorResult(prepared)) return prepared; setCollectorFilter("all"); setTab("collector"); const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0); @@ -3803,12 +4142,10 @@ export function App(): ReactElement { showToast(`${result.addedLinks} von ${linkIds.size} Link(s) übergeben; Sammlung bleibt erhalten`, 3200); return; } - setCollectorPackages((current) => { - const next = removeCollectorLinks(current, linkIds); - collectorPackagesRef.current = next; - return next; - }); - setSelectedCollectorLinkIds((current) => new Set([...current].filter((id) => !linkIds.has(id)))); + const nextCollectorPackages = removeCollectorLinks(collectorPackagesRef.current, linkIds); + collectorPackagesRef.current = nextCollectorPackages; + setCollectorPackages(nextCollectorPackages); + reconcileCollectorDependentState(nextCollectorPackages); setTab("downloads"); showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) übergeben`); if (snapshotRef.current.settings.collapseNewPackages) { @@ -4069,33 +4406,31 @@ export function App(): ReactElement { }; const setCollectorPackageSelection = (packageId: string, selected: boolean): void => { - const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId); - if (pkg) { - setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected)); + const row = collectorViewModel.packages.find((entry) => entry.id === packageId); + if (row) { + setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, row, selected)); } }; const toggleCollectorPackageCollapse = (packageId: string): void => { - setCollapsedCollectorPackageIds((current) => { - const next = new Set(current); - if (next.has(packageId)) next.delete(packageId); - else next.add(packageId); - return next; - }); + const next = new Set(collapsedCollectorPackageIdsRef.current); + if (next.has(packageId)) next.delete(packageId); + else next.add(packageId); + applyCollectorCollapsedPackageIds(next); }; const toggleAllCollectorPackages = (): void => { const packageIds = collectorPackagesRef.current.map((pkg) => pkg.id); - setCollapsedCollectorPackageIds((current) => packageIds.some((id) => !current.has(id)) + applyCollectorCollapsedPackageIds(packageIds.some((id) => !collapsedCollectorPackageIdsRef.current.has(id)) ? new Set(packageIds) : new Set()); }; const removeSelectedCollectorLinks = (): void => { - if (selectedCollectorLinkIds.size === 0) { + const removedIds = new Set(collectorViewModel.selectedIds); + if (removedIds.size === 0) { return; } - const removedIds = new Set(selectedCollectorLinkIds); void askConfirmPrompt({ title: "Ausgewählte Links löschen", message: "Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", @@ -4105,12 +4440,10 @@ export function App(): ReactElement { if (!confirmed) { return; } - setCollectorPackages((current) => { - const next = removeCollectorLinks(current, removedIds); - collectorPackagesRef.current = next; - return next; - }); - setSelectedCollectorLinkIds(new Set()); + const nextCollectorPackages = removeCollectorLinks(collectorPackagesRef.current, removedIds); + collectorPackagesRef.current = nextCollectorPackages; + setCollectorPackages(nextCollectorPackages); + reconcileCollectorDependentState(nextCollectorPackages); setCollectorError(""); }); }; @@ -4902,15 +5235,18 @@ export function App(): ReactElement { } if (!e.shiftKey && e.key.toLowerCase() === "a") { if (inInput) return; - if (tabRef.current === "downloads") { + if (tabRef.current === "downloads") { e.preventDefault(); // Select exactly the VISIBLE rows (packages + their items), honouring // the active search / collapse / hide-extracted filters — selecting // the unfiltered package map would let a later delete hit hidden ones. - setSelectedIds(new Set(visibleOrderIdsRef.current)); + setSelectedIds(new Set(visibleOrderIdsRef.current)); + } else if (tabRef.current === "collector") { + e.preventDefault(); + setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, collectorVisibleIdsRef.current, true)); } else if (tabRef.current === "history") { e.preventDefault(); - setSelectedHistoryIds(selectVisibleHistoryIds(historyVisibleIdsRef.current)); + setSelectedHistoryIds((current) => selectHistoryPageFromShortcut(current, historyVisibleIdsRef.current)); } return; } @@ -5068,10 +5404,15 @@ export function App(): ReactElement { speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps); }, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]); const downloadQueueMetrics = useMemo(() => getDownloadQueueStatusMetrics(downloadsViewCore.eligibleItems), [downloadsViewCore.eligibleItems]); - const downloadQueueTotalBytes = downloadQueueMetrics.totalBytes; + const downloadQueueTotal = downloadQueueMetrics.total; const downloadRemaining = downloadQueueMetrics.remaining; + const downloadRunRemaining = useMemo(() => ({ + bytes: snapshot.runRemainingBytes ?? 0, + unknownItems: snapshot.runRemainingUnknownItems ?? 0 + }), [snapshot.runRemainingBytes, snapshot.runRemainingUnknownItems]); const downloadsViewModel = useMemo(() => ({ ...downloadsViewCore, + query: downloadSearch, running: snapshot.session.running, paused: snapshot.session.paused, canStart: snapshot.canStart, @@ -5105,16 +5446,16 @@ export function App(): ReactElement { links: downloadQueueMetrics.pendingItemCount, session: humanSize(snapshot.stats.totalDownloaded), sessionBytes: snapshot.stats.totalDownloaded, - total: humanSize(downloadQueueTotalBytes), - totalBytes: downloadQueueTotalBytes, + total: formatRemainingDownloadBytes(downloadQueueTotal), + totalBytes: downloadQueueTotal.bytes, remaining: formatRemainingDownloadBytes(downloadRemaining), remainingBytes: downloadRemaining.bytes, remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining), hosters: downloadQueueMetrics.hosterCount, speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", - eta: snapshot.etaText + eta: `ETA: ${formatDownloadEta(downloadRunRemaining, liveDownloadSpeedBps, snapshot.session.running, snapshot.session.paused)}` } - }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueMetrics.hosterCount, downloadQueueMetrics.packageCount, downloadQueueMetrics.pendingItemCount, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded]); + }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueMetrics.hosterCount, downloadQueueMetrics.packageCount, downloadQueueMetrics.pendingItemCount, downloadQueueTotal, downloadRemaining, downloadRunRemaining, downloadSearch, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.reconnectSeconds, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded]); const resetColumnLayout = useCallback((): void => { if (columnDragSettleTimerRef.current !== null) { @@ -5142,16 +5483,28 @@ export function App(): ReactElement { openCollectorInput(); }, onStartDownloads: () => { + const onBlocked = (): void => showToast("Downloads können derzeit nicht gestartet werden", 2400); + const onError = (error: unknown): void => showToast(`Start fehlgeschlagen: ${String(error)}`, 2800); if (snapshot.session.paused) { - setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } })); - void window.rd.togglePause().catch(() => {}); - } else { - void onStartDownloads(); + void runResumeDownloadAction( + performQuickAction, + snapshot.canStart, + () => window.rd.togglePause(), + (paused) => setSnapshot((current) => ({ ...current, session: { ...current.session, paused } })), + onBlocked, + onError + ); + return; } + void runDownloadStartAction(snapshot.canStart, onStartDownloads, onBlocked, onError); }, onPauseDownloads: () => { - setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } })); - void window.rd.togglePause().catch(() => {}); + void runPauseDownloadAction( + performQuickAction, + () => window.rd.togglePause(), + (paused) => setSnapshot((current) => ({ ...current, session: { ...current.session, paused } })), + (error) => showToast(`Pause fehlgeschlagen: ${String(error)}`, 2800) + ); }, onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); }, onToggleSchedule: () => { @@ -5304,6 +5657,7 @@ export function App(): ReactElement { return; } return window.rd.resetSessionStats().then(() => { + speedHistoryRef.current = []; showToast("Session-Statistik zurückgesetzt", 1800); }).catch((error) => { showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400); @@ -5343,12 +5697,17 @@ export function App(): ReactElement { onImportDlc: () => { void onImportDlc(); }, onImportFile: () => { void onImportQueue(); }, onSubmitSelected: () => { - void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds)); + void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, new Set(collectorViewModel.selectedTransferableIds))); + }, + onSubmitAll: () => { + void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, new Set(collectorViewModel.visibleTransferableIds))); }, - onSubmitAll: () => { void submitCollectorPackages(collectorPackagesRef.current); }, onQueryChange: setCollectorQuery, onLinkSelectionChange: setCollectorLinkSelection, onPackageSelectionChange: setCollectorPackageSelection, + onSetVisibleSelection: (ids, selected) => { + setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, ids, selected)); + }, onPackageCollapseChange: toggleCollectorPackageCollapse, onToggleAllPackages: toggleAllCollectorPackages, onRemoveSelected: removeSelectedCollectorLinks @@ -5679,6 +6038,7 @@ export function App(): ReactElement { const choice = value as SettingsThemeChoice; const next = resolveSettingsThemeChoice(choice, window.matchMedia("(prefers-color-scheme: light)").matches); setSettingsThemeChoice(choice); + setText("themePreference", choice); setText("theme", next); applyTheme(next); return; @@ -5974,6 +6334,38 @@ export function App(): ReactElement { /> ) : null; + if (snapshotBootstrapStatus.phase !== "ready") { + const snapshotBootstrapFailed = snapshotBootstrapStatus.phase === "error"; + return ( +
+
+ {!snapshotBootstrapFailed ?
+
+ ); + } + return (
) : tab === "history" ? ( <> - Einträge: {historyViewModel.totalCount} - Sichtbar: {historyViewModel.rows.length} - Ausgewählt: {historyViewModel.selectedIds.length} + {historySidebarCounts.entries} + {historySidebarCounts.visible} + {historySidebarCounts.selected} ) : tab === "statistics" ? ( @@ -6637,18 +7029,30 @@ export function App(): ReactElement { const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]); const hasStartableItems = actionableSelectedIds.some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); const hasItems = selectedItemIds.length > 0; + const startSelected = (): void => { + const packageIds = selectedPackageIds; + const itemIds = selectedItemIds.filter((id) => { const item = snapshot.session.items[id]; return item && startableStatuses.has(item.status); }); + void runDownloadStartAction( + snapshot.canStart, + async () => { + if (packageIds.length > 0) await window.rd.startPackages(packageIds); + if (itemIds.length > 0) await window.rd.startItems(itemIds); + }, + () => showToast("Downloads können derzeit nicht gestartet werden", 2400), + (error) => showToast(`Start fehlgeschlagen: ${String(error)}`, 2800) + ); + setContextMenu(null); + }; return ( setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}> - {(hasPackages || hasStartableItems) && ( - - )} - + { downloadsActions.onStartDownloads(); setContextMenu(null); }} + onStartSelected={startSelected} + selectedLabel={`Ausgewählte Downloads starten${multi ? ` (${actionableSelectedIds.length})` : ""}`} + showSelected={hasPackages || hasStartableItems} + />
{hasPackages && !contextMenu.itemId && ( @@ -6813,7 +7217,7 @@ export function App(): ReactElement { ) : null} historyContextMenu={historyCtxMenu ? (() => { - const selectedEntryIds = [...pruneHistoryIds(selectedHistoryIds, historyVisibleIdsRef.current)]; + const selectedEntryIds = historyViewModel.selectedIds; const multi = selectedEntryIds.length > 1; const contextEntry = historyEntriesRef.current.find(e => e.id === historyCtxMenu.entryId); const hasUrls = (contextEntry?.urls?.length ?? 0) > 0; diff --git a/src/renderer/collector-enrichment.ts b/src/renderer/collector-enrichment.ts index 8e0e8a8..4efbf73 100644 --- a/src/renderer/collector-enrichment.ts +++ b/src/renderer/collector-enrichment.ts @@ -20,6 +20,20 @@ export function beginCollectorEnrichment( return snapshot; } +export function pruneCollectorEnrichmentGenerations( + current: Map, + packages: CollectorPackage[], + activeSnapshots: Iterable> +): void { + const retainedUrls = new Set(packages.flatMap((pkg) => pkg.links.map((link) => collectorUrlKey(link.url)))); + for (const snapshot of activeSnapshots) { + for (const url of snapshot.keys()) retainedUrls.add(url); + } + for (const url of current.keys()) { + if (!retainedUrls.has(url)) current.delete(url); + } +} + export function filterCurrentCollectorEnrichment( packages: CollectorPackage[], requested: CollectorEnrichmentGenerationSnapshot, diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index ad8a6b0..991f4c8 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -8,7 +8,7 @@ const pairs = [ ["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"], ["Einstellungen speichern", "Save settings"], ["Änderungen verwerfen", "Discard changes"], ["Stellt den letzten gespeicherten Stand wieder her.", "Restores the last saved settings."], ["Ungespeicherte Änderungen verworfen", "Unsaved changes discarded"], ["Zwischenstand gespeichert – weitere Änderungen sind ungespeichert", "Progress saved – additional changes remain unsaved"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"], ["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"], - ["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."], + ["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."], ["Hell", "Light"], ["Dunkel", "Dark"], ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], @@ -62,17 +62,31 @@ const pairs = [ ["Verlaufstabelle", "History table"], ["Verlaufsseiten", "History pages"], ["Vorherige Verlaufsseite", "Previous history page"], ["Nächste Verlaufsseite", "Next history page"], ["Zurück", "Back"], ["Vor", "Next"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Verlauf wird geladen. Die gespeicherten Einträge werden geladen.", "History is loading. Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"], ["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."], ["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"], + ["Download gestartet", "Download started"], ["Download beendet", "Download finished"], ["Nachbearbeitung gestartet", "Post-processing started"], ["Gesamtdauer", "Total duration"], + ["Erfolgreich / Fehlgeschlagen / Abgebrochen", "Successful / Failed / Cancelled"], ["Archive / Parts / Ausgaben", "Archives / parts / outputs"], ["Fehlerphase", "Failure phase"], ["Fehlerkategorie", "Error category"], + ["Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung", "Download / Offline / Extraction / Remux / Cleanup / Post-processing"], ["Downloaddauer (Altbestand)", "Download duration (legacy)"], + ["Archivvorgänge", "Archive operations"], ["Remuxvorgänge", "Remux operations"], ["Keine Archivvorgänge", "No archive operations"], ["Keine Remuxvorgänge", "No remux operations"], ["Sichtbar:", "Visible:"], ["pro Seite", "per page"], ["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."], ["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], + ["Entpackte Downloads sind ausgeblendet", "Extracted downloads are hidden"], ["Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.", "Disable “Hide extracted entries” to show them again."], ["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"], ["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"], ["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"], + ["Letzte 24 Stunden", "Last 24 hours"], ["Letzte sieben Tage", "Last seven days"], ["Letzte 30 Tage", "Last 30 days"], ["Seit Beginn der Statistikaufzeichnung", "Since statistics tracking began"], + ["Account-Traffic der vergangenen 24 Stunden.", "Account traffic over the past 24 hours."], ["Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.", "Last 24 hours: values since tracking began."], + ["Heutige Werte stammen aus der lokalen Statistikaufzeichnung.", "Today's values come from local statistics tracking."], ["Datenmenge und Dateien stammen aus den Gesamtzählern; Ergebnisse und Durchschnitt seit Beginn der Statistikaufzeichnung.", "Data volume and files come from the total counters; results and averages cover the period since statistics tracking began."], + ["Dateien werden nicht minutengenau nach Account erfasst", "File counts are not tracked per account at minute precision"], ["Ergebnisse werden nicht minutengenau nach Account erfasst", "Results are not tracked per account at minute precision"], + ["Aktive Downloadzeit wird nur tagesweise erfasst", "Active download time is tracked by day only"], ["Fehler werden nicht minutengenau nach Account erfasst", "Errors are not tracked per account at minute precision"], + ["Noch keine aktive Downloadzeit mit übertragenen Daten erfasst", "No active download time with transferred data has been recorded yet"], ["In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.", "No account traffic has been recorded in the past 24 hours."], ["In diesem Zeitraum wurden noch keine Providerwerte erfasst.", "No provider values have been recorded for this period."], ["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"], ["Bandbreitenverlauf", "Bandwidth history"], ["Bandbreitenverlauf der letzten 60 Sekunden", "Bandwidth history for the last 60 seconds"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"], ["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"], ["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"], ["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"], - ["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"], + ["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Linksammler-Filter", "Link collector filters"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"], + ["Pakete:", "Packages:"], ["Links:", "Links:"], ["Downloads übergeben", "Transfer downloads"], ["Analyse läuft im Hintergrund", "Analysis is running in the background"], ["Name, URL oder Hoster", "Name, URL, or host"], ["Teilweise online", "Partially online"], + ["Suche und Paketdarstellung", "Search and package display"], ["Gesammelte Downloadpakete", "Collected download packages"], ["Hinzugefügt", "Added"], ["Passe Suche oder Statusfilter an.", "Adjust the search or status filter."], + ["Die ersten Links erscheinen sofort nach dem Import.", "The first links appear immediately after import."], ["Links werden vorbereitet", "Links are being prepared"], ["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to check packages before downloading."], ["Links erscheinen sofort und werden anschließend im Hintergrund geprüft.", "Links appear immediately and are then checked in the background."], ["Eine URL pro Zeile", "One URL per line"], ["Keine übertragbaren Links ausgewählt", "No transferable links selected"], ["Alle sichtbaren Links auswählen", "Select all visible links"], ["Der Linksammler ist zu groß, um gespeichert zu werden.", "The link collector is too large to save."], ["Der Queue-Export ist ungültig.", "The queue export is invalid."], ["Der Queue-Export enthält ungültiges JSON.", "The queue export contains invalid JSON."], ["Linksammler-Payload ist ungültig", "Link collector payload is invalid"], ["Linksammler-Anreicherung ist ungültig", "Link collector enrichment is invalid"], ["Linksammler-Speicherzustand ist ungültig", "Link collector persistence state is invalid"], ["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"], ["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."], ["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"], @@ -211,19 +225,104 @@ const prefixedPairs = [ ["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "], ["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "], ["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "], - ["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "] + ["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "], + ["Metadatenprüfung fehlgeschlagen: ", "Metadata check failed: "], ["Linksammler konnte nicht gespeichert werden: ", "Link collector could not be saved: "], ["Linksammler konnte nicht wiederhergestellt werden: ", "Link collector could not be restored: "], ["Links konnten nicht vorbereitet werden: ", "Links could not be prepared: "], ["Übergabe fehlgeschlagen: ", "Transfer failed: "], + ["Start fehlgeschlagen: ", "Start failed: "], ["Pause fehlgeschlagen: ", "Pause failed: "] ] as const; export function normalizeLanguage(value: unknown): AppLanguage { return value === "de" ? "de" : "en"; } +function germanNumberToEnglish(value: string): string { + const [integer, fraction] = value.split(","); + const grouped = integer.replace(/\./g, "").replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return fraction === undefined ? grouped : `${grouped}.${fraction}`; +} + +function englishNumberToGerman(value: string): string { + const [integer, fraction] = value.split("."); + const grouped = integer.replace(/,/g, "").replace(/\B(?=(\d{3})+(?!\d))/g, "."); + return fraction === undefined ? grouped : `${grouped},${fraction}`; +} + +function formatGermanInteger(value: string): string { + return value.replace(/[.,]/g, "").replace(/\B(?=(\d{3})+(?!\d))/g, "."); +} + function translateDynamic(value: string, language: AppLanguage): string { + if (value.startsWith("Error: ")) return `Error: ${translateUiText(value.slice(7), language)}`; + if (language === "en") { + const statisticsStatus = value.match(/^(Daten|Dateien|Erfolg|Fehler|Provider|Accounts): ((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?(?: (?:B|KB|MB|GB|TB|PB)(?:\/s)?| %)?$)/); + if (statisticsStatus) { + const labels: Record = { Daten: "Data", Dateien: "Files", Erfolg: "Success", Fehler: "Errors", Provider: "Providers", Accounts: "Accounts" }; + return `${labels[statisticsStatus[1]]}: ${translateUiText(statisticsStatus[2], language)}`; + } + } else { + const statisticsStatus = value.match(/^(Data|Files|Success|Errors|Providers|Accounts): ((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?: (?:B|KB|MB|GB|TB|PB)(?:\/s)?| %)?$)/); + if (statisticsStatus) { + const labels: Record = { Data: "Daten", Files: "Dateien", Success: "Erfolg", Errors: "Fehler", Providers: "Provider", Accounts: "Accounts" }; + return `${labels[statisticsStatus[1]]}: ${translateUiText(statisticsStatus[2], language)}`; + } + const germanCountAction = value.match(/^(Ausgewählte Downloads starten|Ausgewählte Pakete exportieren|Ausgewählte Dateien exportieren|Ausgewählte Dateien entfernen|Zurücksetzen|überspringen|Ausgewählte entfernen) \(([\d.,]+)\)$/); + if (germanCountAction) return `${germanCountAction[1]} (${formatGermanInteger(germanCountAction[2])})`; + const germanToggleAll = value.match(/^Alle ([\d.,]+) umschalten$/); + if (germanToggleAll) return `Alle ${formatGermanInteger(germanToggleAll[1])} umschalten`; + } for (const [german, english] of prefixedPairs) { const source = language === "en" ? german : english; - if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`; + if (value.startsWith(source)) { + const suffix = value.slice(source.length); + return `${language === "en" ? english : german}${suffix ? translateUiText(suffix, language) : ""}`; + } } if (language === "en") { + const groupedInteger = value.match(/^\d{1,3}(?:\.\d{3})+$/); + if (groupedInteger) return value.replace(/\./g, ","); + const englishMeasurement = value.match(/^(?:\d{1,3}(?:,\d{3})+|\d{1,3})(?:\.\d{1,2})? (?:B|KB|MB|GB|TB|PB)(?:\/s)?$/); + if (englishMeasurement) return value; + const englishPercentage = value.match(/^(?:\d{1,3}(?:,\d{3})+|\d{1,3})(?:\.\d{1,2})? %$/); + if (englishPercentage) return value; + const measurement = value.match(/^((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?) (B|KB|MB|GB|TB|PB)(\/s)?$/); + if (measurement) return `${germanNumberToEnglish(measurement[1])} ${measurement[2]}${measurement[3] ?? ""}`; + const percentage = value.match(/^((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?) %$/); + if (percentage) return `${germanNumberToEnglish(percentage[1])} %`; + const recordedDays = value.match(/^(Letzte sieben Tage|Letzte 30 Tage): (\d+) (erfasster Tag wird|erfasste Tage werden) bis heute zusammengefasst\.$/); + if (recordedDays) return `${recordedDays[1] === "Letzte sieben Tage" ? "Last seven days" : "Last 30 days"}: ${recordedDays[2]} recorded ${recordedDays[3] === "erfasster Tag wird" ? "day is" : "days are"} summarized through today.`; + const statisticsOutcome = value.match(/^([\d.,]+) fertig · ([\d.,]+) Fehler$/); + if (statisticsOutcome) return `${germanNumberToEnglish(statisticsOutcome[1])} completed · ${germanNumberToEnglish(statisticsOutcome[2])} errors`; + const historyParts = value.match(/^([\d.,]+) Parts$/); + if (historyParts) return `${germanNumberToEnglish(historyParts[1])} parts`; + const selectedDownloadsStart = value.match(/^Ausgewählte Downloads starten \(([\d.,]+)\)$/); + if (selectedDownloadsStart) return `Start selected downloads (${germanNumberToEnglish(selectedDownloadsStart[1])})`; + const selectedFilesRemove = value.match(/^Ausgewählte Dateien entfernen \(([\d.,]+)\)$/); + if (selectedFilesRemove) return `Remove selected files (${germanNumberToEnglish(selectedFilesRemove[1])})`; + const resetCount = value.match(/^Zurücksetzen \(([\d.,]+)\)$/); + if (resetCount) return `Reset (${germanNumberToEnglish(resetCount[1])})`; + const skipCount = value.match(/^überspringen \(([\d.,]+)\)$/); + if (skipCount) return `skip (${germanNumberToEnglish(skipCount[1])})`; + const collectorSelected = value.match(/^Auswahl übergeben \(([\d.,]+)\)$/); + if (collectorSelected) return `Send selection (${collectorSelected[1].replace(/\./g, ",")})`; + const collectorAll = value.match(/^Alle übergeben \(([\d.,]+)\)$/); + if (collectorAll) return `Send all (${collectorAll[1].replace(/\./g, ",")})`; + const collectorPackage = value.match(/^Paket (.+) auswählen$/); + if (collectorPackage) return `Select package ${collectorPackage[1]}`; + const collectorFiles = value.match(/^([\d.,]+) Dateien$/); + if (collectorFiles) return `${collectorFiles[1].replace(/\./g, ",")} files`; + const collectorAvailability = value.match(/^([\d.,]+)\/([\d.,]+) online$/); + if (collectorAvailability) return `${collectorAvailability[1].replace(/\./g, ",")}/${collectorAvailability[2].replace(/\./g, ",")} online`; + const collectorCapacity = value.match(/^Der Linksammler kann höchstens ([\d.,]+) (Pakete|Links) enthalten\.$/); + if (collectorCapacity) return `The link collector can contain at most ${collectorCapacity[1].replace(/\./g, ",")} ${collectorCapacity[2] === "Pakete" ? "packages" : "links"}.`; + const collectorImportCapacity = value.match(/^Der Queue-Export überschreitet das Collector-Limit von ([\d.,]+) (Paketen|Links)\.$/); + if (collectorImportCapacity) return `The queue export exceeds the link collector limit of ${collectorImportCapacity[1].replace(/\./g, ",")} ${collectorImportCapacity[2] === "Paketen" ? "packages" : "links"}.`; + const collectorCollected = value.match(/^([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\) gesammelt$/); + if (collectorCollected) return `${collectorCollected[1].replace(/\./g, ",")} package(s), ${collectorCollected[2].replace(/\./g, ",")} link(s) collected`; + const collectorDlcCollected = value.match(/^DLC gesammelt: ([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\)$/); + if (collectorDlcCollected) return `DLC collected: ${collectorDlcCollected[1].replace(/\./g, ",")} package(s), ${collectorDlcCollected[2].replace(/\./g, ",")} link(s)`; + const collectorPartialTransfer = value.match(/^([\d.,]+) von ([\d.,]+) Link\(s\) übergeben; Sammlung bleibt erhalten$/); + if (collectorPartialTransfer) return `${collectorPartialTransfer[1].replace(/\./g, ",")} of ${collectorPartialTransfer[2].replace(/\./g, ",")} link(s) transferred; collection remains`; + const collectorTransferred = value.match(/^([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\) übergeben$/); + if (collectorTransferred) return `${collectorTransferred[1].replace(/\./g, ",")} package(s), ${collectorTransferred[2].replace(/\./g, ",")} link(s) transferred`; const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/); if (update) return `${update[1]} is available. Installed version: ${update[2]}.`; const pagination = value.match(/^([\d.,]+\s*[–-]\s*[\d.,]+) von ([\d.,]+)$/); @@ -234,8 +333,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (scheduled) return `Scheduled: ${scheduled[1].replace(/^Heute\b/, "Today")}`; const cancelled = value.match(/^(\d+) abgebrochen$/); if (cancelled) return `${cancelled[1]} cancelled`; - const labelledCount = value.match(/^(Einträge|Sichtbar|Ausgewählt): (\d+)$/); - if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`; + const labelledCount = value.match(/^(Pakete|Links|Einträge|Sichtbar|Ausgewählt): ([\d.,]+)$/); + if (labelledCount) return `${({ Pakete: "Packages", Links: "Links", Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as Record)[labelledCount[1]]}: ${labelledCount[2].replace(/\./g, ",")}`; const perPage = value.match(/^(\d+) pro Seite$/); if (perPage) return `${perPage[1]} per page`; const pageStatus = value.match(/^Seite ([\d.,\s]+) von ([\d.,\s]+)$/); @@ -364,12 +463,12 @@ function translateDynamic(value: string, language: AppLanguage): string { if (packageCount) return `${packageCount[1]} package(s)`; const linkCount = value.match(/^(\d+) Link\(s\)$/); if (linkCount) return `${linkCount[1]} link(s)`; - const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \((\d+)\)$/); - if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${exportSelected[2]})`; - const toggleAll = value.match(/^Alle (.+) umschalten$/); - if (toggleAll) return `Toggle all ${toggleAll[1]}`; - const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/); - if (removeSelected) return `Remove selected (${removeSelected[1]})`; + const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \(([\d.,]+)\)$/); + if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${germanNumberToEnglish(exportSelected[2])})`; + const toggleAll = value.match(/^Alle ([\d.,]+) umschalten$/); + if (toggleAll) return `Toggle all ${germanNumberToEnglish(toggleAll[1])}`; + const removeSelected = value.match(/^Ausgewählte entfernen \(([\d.,]+)\)$/); + if (removeSelected) return `Remove selected (${germanNumberToEnglish(removeSelected[1])})`; const removeCollection = value.match(/^Soll die Sammlung (.+) mit (\d+) Link\(s\) wirklich entfernt werden\?$/); if (removeCollection) return `Do you really want to remove collection ${removeCollection[1]} with ${removeCollection[2]} link(s)?`; const removeEmptyCollection = value.match(/^Soll die leere Sammlung (.+) wirklich entfernt werden\?$/); @@ -393,6 +492,52 @@ function translateDynamic(value: string, language: AppLanguage): string { .replace(/Fehlgeschlagen nach (\d+) Versuchen/g, "Failed after $1 attempts") .replace(/(\d+) Fehler/g, "$1 errors"); } else { + const groupedInteger = value.match(/^\d{1,3}(?:,\d{3})+$/); + if (groupedInteger) return value.replace(/,/g, "."); + const germanMeasurement = value.match(/^(?:\d{1,3}(?:\.\d{3})+|\d{1,3})(?:,\d{1,2})? (?:B|KB|MB|GB|TB|PB)(?:\/s)?$/); + if (germanMeasurement) return value; + const germanPercentage = value.match(/^(?:\d{1,3}(?:\.\d{3})+|\d{1,3})(?:,\d{1,2})? %$/); + if (germanPercentage) return value; + const measurement = value.match(/^((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?) (B|KB|MB|GB|TB|PB)(\/s)?$/); + if (measurement) return `${englishNumberToGerman(measurement[1])} ${measurement[2]}${measurement[3] ?? ""}`; + const percentage = value.match(/^((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?) %$/); + if (percentage) return `${englishNumberToGerman(percentage[1])} %`; + const recordedDays = value.match(/^(Last seven days|Last 30 days): (\d+) recorded (day is|days are) summarized through today\.$/); + if (recordedDays) return `${recordedDays[1] === "Last seven days" ? "Letzte sieben Tage" : "Letzte 30 Tage"}: ${recordedDays[2]} ${recordedDays[3] === "day is" ? "erfasster Tag wird" : "erfasste Tage werden"} bis heute zusammengefasst.`; + const statisticsOutcome = value.match(/^([\d.,]+) completed · ([\d.,]+) errors$/); + if (statisticsOutcome) return `${englishNumberToGerman(statisticsOutcome[1])} fertig · ${englishNumberToGerman(statisticsOutcome[2])} Fehler`; + const historyParts = value.match(/^([\d.,]+) parts$/); + if (historyParts) return `${englishNumberToGerman(historyParts[1])} Parts`; + const selectedDownloadsStart = value.match(/^Start selected downloads \(([\d.,]+)\)$/); + if (selectedDownloadsStart) return `Ausgewählte Downloads starten (${englishNumberToGerman(selectedDownloadsStart[1])})`; + const selectedFilesRemove = value.match(/^Remove selected files \(([\d.,]+)\)$/); + if (selectedFilesRemove) return `Ausgewählte Dateien entfernen (${englishNumberToGerman(selectedFilesRemove[1])})`; + const resetCount = value.match(/^Reset \(([\d.,]+)\)$/); + if (resetCount) return `Zurücksetzen (${englishNumberToGerman(resetCount[1])})`; + const skipCount = value.match(/^skip \(([\d.,]+)\)$/); + if (skipCount) return `überspringen (${englishNumberToGerman(skipCount[1])})`; + const collectorSelected = value.match(/^Send selection \(([\d.,]+)\)$/); + if (collectorSelected) return `Auswahl übergeben (${collectorSelected[1].replace(/,/g, ".")})`; + const collectorAll = value.match(/^Send all \(([\d.,]+)\)$/); + if (collectorAll) return `Alle übergeben (${collectorAll[1].replace(/,/g, ".")})`; + const collectorPackage = value.match(/^Select package (.+)$/); + if (collectorPackage) return `Paket ${collectorPackage[1]} auswählen`; + const collectorFiles = value.match(/^([\d.,]+) files$/); + if (collectorFiles) return `${collectorFiles[1].replace(/,/g, ".")} Dateien`; + const collectorAvailability = value.match(/^([\d.,]+)\/([\d.,]+) online$/); + if (collectorAvailability) return `${collectorAvailability[1].replace(/,/g, ".")}/${collectorAvailability[2].replace(/,/g, ".")} online`; + const collectorCapacity = value.match(/^The link collector can contain at most ([\d.,]+) (packages|links)\.$/); + if (collectorCapacity) return `Der Linksammler kann höchstens ${collectorCapacity[1].replace(/,/g, ".")} ${collectorCapacity[2] === "packages" ? "Pakete" : "Links"} enthalten.`; + const collectorImportCapacity = value.match(/^The queue export exceeds the link collector limit of ([\d.,]+) (packages|links)\.$/); + if (collectorImportCapacity) return `Der Queue-Export überschreitet das Collector-Limit von ${collectorImportCapacity[1].replace(/,/g, ".")} ${collectorImportCapacity[2] === "packages" ? "Paketen" : "Links"}.`; + const collectorCollected = value.match(/^([\d.,]+) package\(s\), ([\d.,]+) link\(s\) collected$/); + if (collectorCollected) return `${collectorCollected[1].replace(/,/g, ".")} Paket(e), ${collectorCollected[2].replace(/,/g, ".")} Link(s) gesammelt`; + const collectorDlcCollected = value.match(/^DLC collected: ([\d.,]+) package\(s\), ([\d.,]+) link\(s\)$/); + if (collectorDlcCollected) return `DLC gesammelt: ${collectorDlcCollected[1].replace(/,/g, ".")} Paket(e), ${collectorDlcCollected[2].replace(/,/g, ".")} Link(s)`; + const collectorPartialTransfer = value.match(/^([\d.,]+) of ([\d.,]+) link\(s\) transferred; collection remains$/); + if (collectorPartialTransfer) return `${collectorPartialTransfer[1].replace(/,/g, ".")} von ${collectorPartialTransfer[2].replace(/,/g, ".")} Link(s) übergeben; Sammlung bleibt erhalten`; + const collectorTransferred = value.match(/^([\d.,]+) package\(s\), ([\d.,]+) link\(s\) transferred$/); + if (collectorTransferred) return `${collectorTransferred[1].replace(/,/g, ".")} Paket(e), ${collectorTransferred[2].replace(/,/g, ".")} Link(s) übergeben`; const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/); if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`; const pagination = value.match(/^([\d.,]+\s*[–-]\s*[\d.,]+) of ([\d.,]+)$/); @@ -403,8 +548,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (scheduled) return `Geplant: ${scheduled[1].replace(/^Today\b/, "Heute")}`; const cancelled = value.match(/^(\d+) cancelled$/); if (cancelled) return `${cancelled[1]} abgebrochen`; - const labelledCount = value.match(/^(Entries|Visible|Selected): (\d+)$/); - if (labelledCount) return `${({ Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as const)[labelledCount[1] as "Entries" | "Visible" | "Selected"]}: ${labelledCount[2]}`; + const labelledCount = value.match(/^(Packages|Links|Entries|Visible|Selected): ([\d.,]+)$/); + if (labelledCount) return `${({ Packages: "Pakete", Links: "Links", Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as Record)[labelledCount[1]]}: ${labelledCount[2].replace(/,/g, ".")}`; const perPage = value.match(/^(\d+) per page$/); if (perPage) return `${perPage[1]} pro Seite`; const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/); @@ -541,12 +686,12 @@ function translateDynamic(value: string, language: AppLanguage): string { if (packageCount) return `${packageCount[1]} Paket(e)`; const linkCount = value.match(/^(\d+) link\(s\)$/); if (linkCount) return `${linkCount[1]} Link(s)`; - const exportSelected = value.match(/^Export selected (packages|files) \((\d+)\)$/); - if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${exportSelected[2]})`; - const toggleAll = value.match(/^Toggle all (.+)$/); - if (toggleAll) return `Alle ${toggleAll[1]} umschalten`; - const removeSelected = value.match(/^Remove selected \((\d+)\)$/); - if (removeSelected) return `Ausgewählte entfernen (${removeSelected[1]})`; + const exportSelected = value.match(/^Export selected (packages|files) \(([\d.,]+)\)$/); + if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${englishNumberToGerman(exportSelected[2])})`; + const toggleAll = value.match(/^Toggle all ([\d.,]+)$/); + if (toggleAll) return `Alle ${englishNumberToGerman(toggleAll[1])} umschalten`; + const removeSelected = value.match(/^Remove selected \(([\d.,]+)\)$/); + if (removeSelected) return `Ausgewählte entfernen (${englishNumberToGerman(removeSelected[1])})`; const copied = value.match(/^(.+) copied$/); if (copied) return `${copied[1]} kopiert`; const suffixes: Array<[RegExp, string]> = [ diff --git a/src/renderer/styles.css b/src/renderer/styles.css index aafae2c..f81cc4b 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -97,17 +97,98 @@ } } -html, -body, -#root { +html, +body, +#root { margin: 0; width: 100%; height: 100%; background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%); - color: var(--text); -} - -.app-shell { + color: var(--text); +} + +.snapshot-bootstrap-root { + display: grid; + width: 100%; + height: 100%; + place-items: center; + padding: 24px; + background: var(--ui-canvas); + color: var(--ui-text); +} + +.snapshot-bootstrap-panel { + display: grid; + width: min(420px, 100%); + justify-items: center; + gap: 12px; + padding: 32px; + border: 1px solid var(--ui-border); + border-radius: 12px; + background: var(--ui-surface); + text-align: center; +} + +.snapshot-bootstrap-panel h1, +.snapshot-bootstrap-panel p { + margin: 0; +} + +.snapshot-bootstrap-panel h1 { + font-size: 18px; +} + +.snapshot-bootstrap-panel p { + max-width: 360px; + color: var(--ui-text-muted); +} + +.snapshot-bootstrap-panel.is-error h1 { + color: var(--ui-danger-text); +} + +.snapshot-bootstrap-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--ui-border); + border-top-color: var(--ui-primary); + border-radius: 50%; + animation: snapshot-bootstrap-spin 800ms linear infinite; +} + +.snapshot-bootstrap-retry { + min-height: 34px; + padding: 6px 14px; + border: 1px solid var(--ui-primary); + border-radius: 7px; + background: var(--ui-primary); + color: var(--ui-primary-text); + cursor: pointer; + font-weight: 600; +} + +.snapshot-bootstrap-retry:hover { + background: var(--ui-primary-hover); +} + +.snapshot-bootstrap-retry:focus-visible { + outline: 2px solid var(--ui-focus); + outline-offset: 2px; +} + +@keyframes snapshot-bootstrap-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .snapshot-bootstrap-spinner { + animation: none; + } +} + +.app-shell { display: grid; grid-template-rows: auto auto auto 1fr auto; height: 100%; @@ -3102,9 +3183,14 @@ td { transition: background 0.1s; } -.ctx-menu-item:hover { - background: var(--button-bg-hover); -} +.ctx-menu-item:disabled { + opacity: 0.5; + cursor: default; +} + +.ctx-menu-item:hover:not(:disabled) { + background: var(--button-bg-hover); +} .ctx-menu-item.ctx-danger { color: var(--danger); diff --git a/src/renderer/theme.css b/src/renderer/theme.css index d7f370e..c3d1a39 100644 --- a/src/renderer/theme.css +++ b/src/renderer/theme.css @@ -1,8 +1,9 @@ :root, :root[data-theme="dark"] { - --ui-canvas: #0F0F0F; - --ui-surface: #232323; - --ui-input: #2B2B2B; + --ui-canvas: #0F0F0F; + --ui-surface: #232323; + --ui-panel: var(--ui-surface); + --ui-input: #2B2B2B; --ui-table-header: #313131; --ui-active: #333436; --ui-hover: #373535; diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 2082398..36ff272 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -14,6 +14,7 @@ import type { import "./collector.css"; const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; +const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); export interface CollectorViewActions { onFilterChange: (filter: CollectorWorkspaceFilter) => void; @@ -25,6 +26,7 @@ export interface CollectorViewActions { onQueryChange: (value: string) => void; onLinkSelectionChange: (linkId: string, selected: boolean) => void; onPackageSelectionChange: (packageId: string, selected: boolean) => void; + onSetVisibleSelection: (ids: string[], selected: boolean) => void; onPackageCollapseChange: (packageId: string) => void; onToggleAllPackages: () => void; onRemoveSelected: () => void; @@ -55,9 +57,9 @@ function packageStatus(row: CollectorWorkspacePackageRow): string { } function packageAvailability(row: CollectorWorkspacePackageRow): string { - if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`; + if (row.onlineCount === row.totalCount) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`; if (row.offlineCount === row.totalCount) return "Offline"; - if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`; + if (row.onlineCount > 0) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`; return "Ungeprüft"; } @@ -195,7 +197,7 @@ function CollectorHosterLabel({ hoster }: { hoster: ReturnType -
Status{model.totalCount}
+
Status{integerFormatter.format(model.totalCount)}
{model.filters.map((filter) => ( ))} @@ -218,9 +220,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement { return ( <> - Pakete: {model.packageCount} - Links: {model.totalCount} - Ausgewählt: {model.selectedCount} + Pakete: {integerFormatter.format(model.packageCount)} + Links: {integerFormatter.format(model.totalCount)} + Ausgewählt: {integerFormatter.format(model.selectedCount)} {model.analyzing ? ( ) : null} @@ -237,8 +239,8 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE - - + + @@ -249,6 +251,32 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE ); } +export function CollectorTableHeader({ model, actions, scrollLeft }: Pick & { scrollLeft: number }): ReactElement { + const allVisibleSelected = model.visibleIds.length > 0 && model.selectedCount === model.visibleIds.length; + const partiallyVisibleSelected = model.selectedCount > 0 && !allVisibleSelected; + return ( +
+ + actions.onSetVisibleSelection(model.visibleIds, event.target.checked)} + ref={(node) => { if (node) node.indeterminate = partiallyVisibleSelected; }} + type="checkbox" + /> + + Name + Größe + Hoster + Status + Verfügbarkeit + Hinzugefügt +
+ ); +} + function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: { focusIndexStart: number; row: CollectorWorkspacePackageRow; @@ -257,7 +285,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, selected: ReadonlySet; rowIndexStart: number; }): ReactElement { - const allSelected = row.selectedCount === row.totalCount; + const allSelected = row.links.length > 0 && row.selectedCount === row.links.length; const partiallySelected = row.selectedCount > 0 && !allSelected; const animateItems = model.animationsEnabled && row.allLinks.length <= 64; const [renderItems, setRenderItems] = useState(!row.collapsed); @@ -314,7 +342,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, type="button" >{row.collapsed ? "+" : "−"} {row.name} - {row.totalCount} Dateien + {integerFormatter.format(row.totalCount)} Dateien
{packageSize(row)} @@ -426,15 +454,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
-
- - Name - Größe - Hoster - Status - Verfügbarkeit - Hinzugefügt -
+
unknown; + saveStateSync: (state: CollectorPersistenceState) => boolean; + maximumBytes?: number; +} + +export interface CollectorBeforeUnloadEvent { + preventDefault: () => void; + returnValue: string; +} + +function mergeCollapsedPackageIds( + persistedState: CollectorPersistenceState, + currentState: CollectorPersistenceState, + packages: CollectorPersistenceState["packages"] +): string[] { + const persistedCollapsed = reconcileCollectorCollapsedPackageIds( + new Set(persistedState.collapsedPackageIds), + persistedState.packages, + packages, + [], + false + ); + const currentCollapsed = reconcileCollectorCollapsedPackageIds( + new Set(currentState.collapsedPackageIds), + currentState.packages, + packages, + [], + false + ); + return [...new Set([...persistedCollapsed, ...currentCollapsed])]; +} + +export function resolveCollectorLateHydration( + persistedState: CollectorPersistenceState, + currentState: CollectorPersistenceState, + maximumBytes?: number +): CollectorLateHydrationResult { + let persisted = persistedState; + let current = currentState; + let attemptedState: CollectorPersistenceState = { + packages: [], + collapsedPackageIds: [] + }; + try { + persisted = validateCollectorPersistenceState(persistedState); + current = validateCollectorPersistenceState(currentState); + const packages = mergeCollectorPackages(persisted.packages, current.packages).packages; + attemptedState = { + packages, + collapsedPackageIds: mergeCollapsedPackageIds(persisted, current, packages) + }; + const capacity = inspectCollectorCapacity(packages); + if (!capacity.ok) { + return { + ...capacity, + attemptedState, + currentState: current, + rollbackState: persisted + }; + } + const persistenceSize = inspectCollectorPersistenceSize(attemptedState, maximumBytes); + if (!persistenceSize.ok) { + return { + ok: false, + message: persistenceSize.message, + packageCount: capacity.packageCount, + linkCount: capacity.linkCount, + attemptedState, + currentState: current, + rollbackState: persisted + }; + } + return { + ok: true, + state: validateCollectorPersistenceState(attemptedState) + }; + } catch (error) { + const capacity = inspectCollectorCapacity(attemptedState.packages); + return { + ok: false, + message: error instanceof Error + ? `Linksammler konnte nicht wiederhergestellt werden: ${error.message}` + : "Linksammler konnte nicht wiederhergestellt werden.", + packageCount: capacity.packageCount, + linkCount: capacity.linkCount, + attemptedState, + currentState: current, + rollbackState: persisted + }; + } +} + +export function persistCollectorStateBeforeUnload( + request: CollectorBeforeUnloadPersistenceRequest +): boolean { + try { + const current = validateCollectorPersistenceState(request.currentState); + if (request.hydrated) { + if (!inspectCollectorPersistenceSize(current, request.maximumBytes).ok) return false; + return request.saveStateSync(current) === true; + } + const hydration = resolveCollectorLateHydration( + validateCollectorPersistenceState(request.getPersistedStateSync()), + current, + request.maximumBytes + ); + if (!hydration.ok) return false; + return request.saveStateSync(hydration.state) === true; + } catch { + return false; + } +} + +export function guardCollectorBeforeUnload( + event: CollectorBeforeUnloadEvent, + request: CollectorBeforeUnloadPersistenceRequest +): boolean { + if (persistCollectorStateBeforeUnload(request)) return true; + event.preventDefault(); + event.returnValue = ""; + return false; +} diff --git a/src/renderer/views/collector/collector-model.ts b/src/renderer/views/collector/collector-model.ts index 24235b9..3701109 100644 --- a/src/renderer/views/collector/collector-model.ts +++ b/src/renderer/views/collector/collector-model.ts @@ -1,4 +1,4 @@ -import type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector"; +import { inspectCollectorCapacity, type CollectorAvailability, type CollectorLink, type CollectorPackage } from "../../../shared/collector"; export type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector"; export type CollectorWorkspaceFilter = "all" | CollectorAvailability; @@ -37,7 +37,10 @@ export interface CollectorWorkspaceViewModel { empty: boolean; totalCount: number; selectedCount: number; + visibleIds: string[]; + visibleTransferableIds: string[]; selectedIds: string[]; + selectedTransferableIds: string[]; animationsEnabled: boolean; } @@ -46,8 +49,39 @@ export interface CollectorMergeResult { addedLinks: number; duplicateLinks: number; enrichedLinks: number; + persistenceDelta: CollectorPersistenceDelta; } +export interface CollectorPersistenceLinkDelta { + url: string; + previousPackageId: string | null; + previousLink: CollectorLink | null; + nextPackageId: string | null; + nextLink: CollectorLink | null; +} + +export interface CollectorPersistencePackageDelta { + package: Pick; + linkCount: number; +} + +export interface CollectorPersistenceDelta { + links: CollectorPersistenceLinkDelta[]; + packages: CollectorPersistencePackageDelta[]; + removedPackageIds: string[]; + packageCount: number; +} + +export type CollectorCapacityMergeResult = { + ok: true; + value: CollectorMergeResult; +} | { + ok: false; + packageCount: number; + linkCount: number; + message: string; +}; + function collectorUrlKey(url: string): string { return url.trim(); } @@ -96,6 +130,8 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co const replacements = new Map(); const movedLinks = new Set(); const appendedLinks = new Map(); + const affectedPackages = new Set(); + const persistenceLinkDeltas = new Map(); const appendToPackage = (pkg: CollectorPackage, link: CollectorLink): void => { const links = appendedLinks.get(pkg); if (links) links.push(link); @@ -141,8 +177,12 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co }; packages.push(target); packageByName.set(incomingPackageKey, target); + affectedPackages.add(target); + } + if (incomingPackage.nameSource === "explicit" && target.nameSource !== "explicit") { + target.nameSource = "explicit"; + affectedPackages.add(target); } - if (incomingPackage.nameSource === "explicit") target.nameSource = "explicit"; if (existing) { const enriched = mergeCollectorLinkMetadata(existing.link, incomingLink); @@ -150,30 +190,80 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co else { movedLinks.add(existing.link); appendToPackage(target, enriched); + affectedPackages.add(existing.pkg); } + affectedPackages.add(target); target.addedAt = Math.min(target.addedAt, enriched.addedAt); existingByUrl.set(urlKey, { pkg: target, link: enriched }); + persistenceLinkDeltas.set(urlKey, { + url: urlKey, + previousPackageId: existing.pkg.id, + previousLink: existing.link, + nextPackageId: target.id, + nextLink: enriched + }); enrichedLinks += 1; continue; } const added = { ...incomingLink }; appendToPackage(target, added); + affectedPackages.add(target); target.addedAt = Math.min(target.addedAt, added.addedAt); existingByUrl.set(urlKey, { pkg: target, link: added }); + persistenceLinkDeltas.set(urlKey, { + url: urlKey, + previousPackageId: null, + previousLink: null, + nextPackageId: target.id, + nextLink: added + }); addedLinks += 1; } } + const finalPackages: CollectorPackage[] = []; + const persistencePackages: CollectorPersistencePackageDelta[] = []; + const removedPackageIds: string[] = []; + for (const pkg of packages) { + const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]); + links.push(...(appendedLinks.get(pkg) ?? [])); + if (links.length === 0) { + if (affectedPackages.has(pkg)) removedPackageIds.push(pkg.id); + continue; + } + const finalPackage = { ...pkg, links }; + finalPackages.push(finalPackage); + if (affectedPackages.has(pkg)) { + persistencePackages.push({ + package: { + id: finalPackage.id, + name: finalPackage.name, + nameSource: finalPackage.nameSource, + addedAt: finalPackage.addedAt + }, + linkCount: finalPackage.links.length + }); + } + for (const link of links) { + const delta = persistenceLinkDeltas.get(collectorUrlKey(link.url)); + if (!delta) continue; + delta.nextPackageId = finalPackage.id; + delta.nextLink = link; + } + } + return { - packages: packages.flatMap((pkg) => { - const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]); - links.push(...(appendedLinks.get(pkg) ?? [])); - return links.length > 0 ? [{ ...pkg, links }] : []; - }), + packages: finalPackages, addedLinks, duplicateLinks, - enrichedLinks + enrichedLinks, + persistenceDelta: { + links: [...persistenceLinkDeltas.values()], + packages: persistencePackages, + removedPackageIds, + packageCount: finalPackages.length + } }; } @@ -186,6 +276,23 @@ export function mergeCollectorEnrichment(current: CollectorPackage[], incoming: return mergeCollectorPackages(current, retained); } +export function mergeCollectorPackagesWithinCapacity( + current: CollectorPackage[], + incoming: CollectorPackage[], + enrichment = false +): CollectorCapacityMergeResult { + const value = enrichment + ? mergeCollectorEnrichment(current, incoming) + : mergeCollectorPackages(current, incoming); + const capacity = inspectCollectorCapacity(value.packages); + return capacity.ok ? { ok: true, value } : capacity; +} + +function retainAvailableCollectorIds(current: ReadonlySet, availableIds: Iterable): Set { + const available = new Set(availableIds); + return new Set([...current].filter((id) => available.has(id))); +} + export function reconcileCollectorCollapsedPackageIds( current: Set, previousPackages: CollectorPackage[], @@ -217,7 +324,14 @@ export function reconcileCollectorCollapsedPackageIds( return next; } -export function selectCollectorPackageLinks(current: Set, pkg: CollectorPackage, selected: boolean): Set { +export function reconcileCollectorCollapsedPackageIdsWithPackages( + current: ReadonlySet, + packages: readonly Pick[] +): Set { + return retainAvailableCollectorIds(current, packages.map((pkg) => pkg.id)); +} + +export function selectCollectorPackageLinks(current: Set, pkg: Pick, selected: boolean): Set { const next = new Set(current); for (const link of pkg.links) { if (selected) next.add(link.id); @@ -226,6 +340,22 @@ export function selectCollectorPackageLinks(current: Set, pkg: Collector return next; } +export function setCollectorVisibleSelection(current: ReadonlySet, visibleIds: readonly string[], selected: boolean): Set { + const next = new Set(current); + for (const id of visibleIds) { + if (selected) next.add(id); + else next.delete(id); + } + return next; +} + +export function reconcileCollectorSelectionWithPackages( + current: ReadonlySet, + packages: readonly Pick[] +): Set { + return retainAvailableCollectorIds(current, packages.flatMap((pkg) => pkg.links.map((link) => link.id))); +} + export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set): CollectorPackage[] { return packages.flatMap((pkg) => { const links = pkg.links.filter((link) => selectedIds.has(link.id)); @@ -240,6 +370,13 @@ export function removeCollectorLinks(packages: CollectorPackage[], removedIds: S }); } +export function reconcileCollectorSelectionAfterRemoval( + selectedIds: ReadonlySet, + removedIds: ReadonlySet +): Set { + return new Set([...selectedIds].filter((id) => !removedIds.has(id))); +} + function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean { return filter === "all" || link.availability === filter; } @@ -277,7 +414,6 @@ export function buildCollectorWorkspaceViewModel( let onlineCount = 0; let offlineCount = 0; let unknownCount = 0; - let selectedCount = 0; const hosters = new Set(); for (const link of pkg.links) { if (link.fileSizeBytes === null) unknownSizeCount += 1; @@ -285,9 +421,9 @@ export function buildCollectorWorkspaceViewModel( if (link.availability === "online") onlineCount += 1; else if (link.availability === "offline") offlineCount += 1; else unknownCount += 1; - if (selected.has(link.id)) selectedCount += 1; if (link.hoster) hosters.add(link.hoster); } + const selectedCount = visibleLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0); rows.push({ id: pkg.id, name: pkg.name, @@ -306,6 +442,13 @@ export function buildCollectorWorkspaceViewModel( }); } + const visibleIds = rows.flatMap((row) => row.links.map((link) => link.id)); + const visibleSelectedIds = visibleIds.filter((id) => selected.has(id)); + const visibleTransferableIds = rows.flatMap((row) => row.links + .filter((link) => link.availability !== "offline") + .map((link) => link.id)); + const selectedTransferableIds = visibleTransferableIds.filter((id) => selected.has(id)); + return { packages: rows, packageCount: packages.length, @@ -321,8 +464,11 @@ export function buildCollectorWorkspaceViewModel( error, empty: rows.length === 0, totalCount: allLinks.length, - selectedCount: allLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0), - selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id), + selectedCount: visibleSelectedIds.length, + visibleIds, + visibleTransferableIds, + selectedIds: visibleSelectedIds, + selectedTransferableIds, animationsEnabled }; } diff --git a/src/renderer/views/collector/collector-persistence-budget.ts b/src/renderer/views/collector/collector-persistence-budget.ts new file mode 100644 index 0000000..bacbb66 --- /dev/null +++ b/src/renderer/views/collector/collector-persistence-budget.ts @@ -0,0 +1,261 @@ +import { + COLLECTOR_MAX_PERSISTENCE_BYTES, + type CollectorLink, + type CollectorPackage, + type CollectorPersistenceState +} from "../../../shared/collector"; +import type { CollectorPersistenceDelta } from "./collector-model"; + +const encoder = new TextEncoder(); +const overflowMessage = "Der Linksammler ist zu groß, um gespeichert zu werden."; + +export interface CollectorPersistenceBudget { + byteCount: number; + packageByteTotal: number; + packageCount: number; + packageByteCounts: ReadonlyMap; + packageBaseByteCounts: ReadonlyMap; + packageLinkByteTotals: ReadonlyMap; + packageLinkCounts: ReadonlyMap; + linkByteCounts: ReadonlyMap; + linkPackageIds: ReadonlyMap; + linkIds: ReadonlyMap; +} + +export type CollectorPersistenceBudgetResult = { + ok: true; + byteCount: number; + nextBudget: CollectorPersistenceBudget; +} | { + ok: false; + byteCount: number; + message: string; +}; + +interface PackageCandidate { + id: string; + existed: boolean; + oldByteCount: number; + baseByteCount: number; + linkByteTotal: number; + linkCount: number; + removed: boolean; +} + +interface LinkCandidate { + byteCount: number; + id: string; + packageId: string; +} + +type MutableCollectorPersistenceBudget = CollectorPersistenceBudget & { + packageByteCounts: Map; + packageBaseByteCounts: Map; + packageLinkByteTotals: Map; + packageLinkCounts: Map; + linkByteCounts: Map; + linkPackageIds: Map; + linkIds: Map; +}; + +function utf8ByteCount(value: string): number { + return encoder.encode(value).byteLength; +} + +function normalizedUrl(url: string): string { + return url.trim(); +} + +function linkByteCount(link: CollectorLink): number { + return utf8ByteCount(JSON.stringify(link)); +} + +function samePersistentLink(left: CollectorLink, right: CollectorLink): boolean { + return left.id === right.id + && left.url === right.url + && left.fileName === right.fileName + && left.fileSizeBytes === right.fileSizeBytes + && left.hoster === right.hoster + && left.availability === right.availability + && left.status === right.status + && left.addedAt === right.addedAt; +} + +function packageBaseByteCount(pkg: Pick): number { + return utf8ByteCount(JSON.stringify({ + id: pkg.id, + name: pkg.name, + nameSource: pkg.nameSource, + links: [], + addedAt: pkg.addedAt + })); +} + +function completePackageByteCount(baseByteCount: number, linkByteTotal: number, linkCount: number): number { + return baseByteCount + linkByteTotal + Math.max(0, linkCount - 1); +} + +function envelopeByteCount(collapsedPackageIds: readonly string[]): number { + return utf8ByteCount(JSON.stringify({ + version: 1, + packages: [], + collapsedPackageIds, + updatedAt: Number.MAX_SAFE_INTEGER + })); +} + +function totalByteCount(packageByteTotal: number, packageCount: number, collapsedPackageIds: readonly string[]): number { + return envelopeByteCount(collapsedPackageIds) + packageByteTotal + Math.max(0, packageCount - 1); +} + +export function createCollectorPersistenceBudget( + state: CollectorPersistenceState, + maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES +): CollectorPersistenceBudgetResult { + const packageByteCounts = new Map(); + const packageBaseByteCounts = new Map(); + const packageLinkByteTotals = new Map(); + const packageLinkCounts = new Map(); + const linkByteCounts = new Map(); + const linkPackageIds = new Map(); + const linkIds = new Map(); + let packageByteTotal = 0; + for (const pkg of state.packages) { + const baseByteCount = packageBaseByteCount(pkg); + let linkByteTotal = 0; + for (const link of pkg.links) { + const url = normalizedUrl(link.url); + const byteCount = linkByteCount(link); + linkByteCounts.set(url, byteCount); + linkPackageIds.set(url, pkg.id); + linkIds.set(url, link.id); + linkByteTotal += byteCount; + } + const byteCount = completePackageByteCount(baseByteCount, linkByteTotal, pkg.links.length); + packageByteCounts.set(pkg.id, byteCount); + packageBaseByteCounts.set(pkg.id, baseByteCount); + packageLinkByteTotals.set(pkg.id, linkByteTotal); + packageLinkCounts.set(pkg.id, pkg.links.length); + packageByteTotal += byteCount; + } + const packageCount = state.packages.length; + const byteCount = totalByteCount(packageByteTotal, packageCount, state.collapsedPackageIds); + if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage }; + return { + ok: true, + byteCount, + nextBudget: { + byteCount, + packageByteTotal, + packageCount, + packageByteCounts, + packageBaseByteCounts, + packageLinkByteTotals, + packageLinkCounts, + linkByteCounts, + linkPackageIds, + linkIds + } + }; +} + +export function advanceCollectorPersistenceBudget( + budget: CollectorPersistenceBudget, + delta: CollectorPersistenceDelta, + collapsedPackageIds: readonly string[], + maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES +): CollectorPersistenceBudgetResult { + const packageCandidates = new Map(); + const linkCandidates = new Map(); + const packageCandidate = (id: string): PackageCandidate => { + const existing = packageCandidates.get(id); + if (existing) return existing; + const existed = budget.packageByteCounts.has(id); + const candidate = { + id, + existed, + oldByteCount: budget.packageByteCounts.get(id) ?? 0, + baseByteCount: budget.packageBaseByteCounts.get(id) ?? 0, + linkByteTotal: budget.packageLinkByteTotals.get(id) ?? 0, + linkCount: budget.packageLinkCounts.get(id) ?? 0, + removed: false + }; + packageCandidates.set(id, candidate); + return candidate; + }; + + for (const change of delta.links) { + const url = normalizedUrl(change.url); + const previousPackageId = budget.linkPackageIds.get(url) ?? change.previousPackageId; + const previousByteCount = budget.linkByteCounts.get(url); + if (previousPackageId && previousByteCount !== undefined) { + const previousPackage = packageCandidate(previousPackageId); + previousPackage.linkByteTotal -= previousByteCount; + previousPackage.linkCount -= 1; + } + if (change.nextPackageId && change.nextLink) { + const canReuse = previousByteCount !== undefined + && change.previousLink !== null + && samePersistentLink(change.previousLink, change.nextLink); + const byteCount = canReuse ? previousByteCount : linkByteCount(change.nextLink); + const nextPackage = packageCandidate(change.nextPackageId); + nextPackage.linkByteTotal += byteCount; + nextPackage.linkCount += 1; + linkCandidates.set(url, { byteCount, id: change.nextLink.id, packageId: change.nextPackageId }); + } else { + linkCandidates.set(url, null); + } + } + + const removedPackageIds = new Set(delta.removedPackageIds); + for (const change of delta.packages) { + const candidate = packageCandidate(change.package.id); + candidate.baseByteCount = packageBaseByteCount(change.package); + candidate.linkCount = change.linkCount; + candidate.removed = false; + removedPackageIds.delete(change.package.id); + } + for (const id of removedPackageIds) packageCandidate(id).removed = true; + + let packageByteTotal = budget.packageByteTotal; + const nextPackageByteCounts = new Map(); + for (const candidate of packageCandidates.values()) { + if (candidate.existed) packageByteTotal -= candidate.oldByteCount; + if (candidate.removed) continue; + const byteCount = completePackageByteCount(candidate.baseByteCount, candidate.linkByteTotal, candidate.linkCount); + nextPackageByteCounts.set(candidate.id, byteCount); + packageByteTotal += byteCount; + } + const byteCount = totalByteCount(packageByteTotal, delta.packageCount, collapsedPackageIds); + if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage }; + + const nextBudget = budget as MutableCollectorPersistenceBudget; + for (const candidate of packageCandidates.values()) { + if (candidate.removed) { + nextBudget.packageByteCounts.delete(candidate.id); + nextBudget.packageBaseByteCounts.delete(candidate.id); + nextBudget.packageLinkByteTotals.delete(candidate.id); + nextBudget.packageLinkCounts.delete(candidate.id); + continue; + } + nextBudget.packageByteCounts.set(candidate.id, nextPackageByteCounts.get(candidate.id) ?? 0); + nextBudget.packageBaseByteCounts.set(candidate.id, candidate.baseByteCount); + nextBudget.packageLinkByteTotals.set(candidate.id, candidate.linkByteTotal); + nextBudget.packageLinkCounts.set(candidate.id, candidate.linkCount); + } + for (const [url, candidate] of linkCandidates) { + if (!candidate) { + nextBudget.linkByteCounts.delete(url); + nextBudget.linkPackageIds.delete(url); + nextBudget.linkIds.delete(url); + continue; + } + nextBudget.linkByteCounts.set(url, candidate.byteCount); + nextBudget.linkPackageIds.set(url, candidate.packageId); + nextBudget.linkIds.set(url, candidate.id); + } + nextBudget.byteCount = byteCount; + nextBudget.packageByteTotal = packageByteTotal; + nextBudget.packageCount = delta.packageCount; + return { ok: true, byteCount, nextBudget }; +} diff --git a/src/renderer/views/collector/collector-persistence.ts b/src/renderer/views/collector/collector-persistence.ts index c2ce436..cbbf8f5 100644 --- a/src/renderer/views/collector/collector-persistence.ts +++ b/src/renderer/views/collector/collector-persistence.ts @@ -36,26 +36,54 @@ export function restoreCollectorPersistenceState( export interface CollectorPersistenceCoordinator { schedule: (state: CollectorPersistenceState) => void; + setBaseline: (state: CollectorPersistenceState) => void; flush: () => Promise; dispose: () => void; } +export interface CollectorPersistenceFailure { + error: unknown; + attemptedState: CollectorPersistenceState; + rollbackState: CollectorPersistenceState | null; +} + export function createCollectorPersistenceCoordinator( save: (state: CollectorPersistenceState) => Promise, - delayMs = 300 + delayMs = 300, + onFailure?: (failure: CollectorPersistenceFailure) => void ): CollectorPersistenceCoordinator { let timer: ReturnType | null = null; let pending: CollectorPersistenceState | null = null; let running: Promise | null = null; + let rollbackState: CollectorPersistenceState | null = null; let disposed = false; + const reportFailure = (error: unknown, attemptedState: CollectorPersistenceState): void => { + try { + onFailure?.({ error, attemptedState, rollbackState }); + } catch { + } + }; + const drain = (): Promise => { if (running) return running; const task = (async () => { while (pending && !disposed) { - const state = pending; + const attemptedState = pending; pending = null; - await save(state); + let state: CollectorPersistenceState; + try { + state = validateCollectorPersistenceState(attemptedState); + } catch (error) { + reportFailure(error, attemptedState); + continue; + } + try { + await save(state); + rollbackState = state; + } catch (error) { + reportFailure(error, attemptedState); + } } })().finally(() => { if (running === task) running = null; @@ -67,19 +95,30 @@ export function createCollectorPersistenceCoordinator( return { schedule: (state) => { if (disposed) return; - pending = validateCollectorPersistenceState(state); - if (timer) clearTimeout(timer); - timer = setTimeout(() => { + pending = state; + try { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void drain(); + }, Math.max(0, delayMs)); + } catch { timer = null; - void drain().catch(() => {}); - }, Math.max(0, delayMs)); + void drain(); + } + }, + setBaseline: (state) => { + rollbackState = state; }, flush: async () => { if (timer) { clearTimeout(timer); timer = null; } - await drain(); + while (!disposed) { + await drain(); + if (!pending) break; + } }, dispose: () => { disposed = true; diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index fb557c9..d05e329 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -79,8 +79,99 @@ export interface DownloadsViewActions extends DownloadsTableActions { onToggleClipboardWatcher: () => void; onClearAll: () => void; onToggleAllPackages: () => void; - onShowAllPackages: () => void; -} + onShowAllPackages: () => void; +} + +export interface DownloadContextStartActionsProps { + actionBusy: boolean; + canStart: boolean; + showSelected: boolean; + selectedLabel: string; + onStartSelected: () => void; + onStartAll: () => void; +} + +export async function runDownloadStartAction( + canStart: boolean, + action: () => Promise, + onBlocked: () => void, + onError: (error: unknown) => void +): Promise { + if (!canStart) { + onBlocked(); + return false; + } + try { + await action(); + return true; + } catch (error) { + onError(error); + return false; + } +} + +export function resumeDownloadSession( + canStart: boolean, + togglePause: () => Promise, + applyPaused: (paused: boolean) => void, + onBlocked: () => void, + onError: (error: unknown) => void +): Promise { + return runDownloadStartAction(canStart, async () => { + applyPaused(await togglePause()); + }, onBlocked, onError); +} + +export async function pauseDownloadSession( + togglePause: () => Promise, + applyPaused: (paused: boolean) => void, + onError: (error: unknown) => void +): Promise { + try { + applyPaused(await togglePause()); + return true; + } catch (error) { + onError(error); + return false; + } +} + +export function runResumeDownloadAction( + runSingleFlight: (action: () => Promise) => Promise, + canStart: boolean, + togglePause: () => Promise, + applyPaused: (paused: boolean) => void, + onBlocked: () => void, + onError: (error: unknown) => void +): Promise { + return runSingleFlight(() => resumeDownloadSession(canStart, togglePause, applyPaused, onBlocked, onError)); +} + +export function runPauseDownloadAction( + runSingleFlight: (action: () => Promise) => Promise, + togglePause: () => Promise, + applyPaused: (paused: boolean) => void, + onError: (error: unknown) => void +): Promise { + return runSingleFlight(() => pauseDownloadSession(togglePause, applyPaused, onError)); +} + +export function DownloadContextStartActions({ + actionBusy, + canStart, + showSelected, + selectedLabel, + onStartSelected, + onStartAll +}: DownloadContextStartActionsProps): ReactElement { + const disabled = !canStart || actionBusy; + return ( + <> + {showSelected ? : null} + + + ); +} const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [ { id: "all", label: "Alle" }, @@ -95,7 +186,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct return (