Backup: nur Settings als Default + 4 Selektions/Flicker-Bugfixes
Backup:
- Neues Setting backupIncludeDownloads (Default aus) — Backup sichert
standardmaessig NUR Einstellungen, nicht die Download-Liste/History.
- buildBackupPayload/planBackupImport (testbare backup-payload.ts): Export
omittet session+history wenn Flag aus (explizites kind-Marker); Import folgt
dem FILE-Inhalt, nicht dem lokalen Toggle.
- importBackup: settings-only -> frueher Return nach setSettings, KEIN stop/
Queue-Wipe/Relaunch. Return {restored,relaunch,message}; main.ts gated den
Auto-Relaunch auf relaunch. Renderer re-seeded settingsDraft bei !relaunch.
Bugfixes:
- Ctrl+A waehlte das ungefilterte Paket-Map -> Loeschen nach Suche traf
versteckte Pakete. Jetzt visibleOrderIds (sichtbare Zeilen, inkl. Items).
- selectedIds nie geprunt bei Delta-Removal -> aufgeblaehte Counts. Neue pure
pruneSelection (selection.ts) + Effect.
- link-status-dot conditional -> Dateiname sprang ~14px. Platzhalter-Slot.
- sortPackagesForDisplay sortierte aktive Pakete nach Live-Progress -> Reshuffle
pro Tick. Jetzt stabile Queue-Reihenfolge je Gruppe (Anti-Flicker).
+17 Tests (backup-payload 9, selection 5, package-order anti-flicker 3).
This commit is contained in:
parent
3ed3877ac9
commit
d006a60553
@ -39,6 +39,7 @@ import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves,
|
|||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
|
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||||
import { runStartupHealthCheck } from "./startup-health-check";
|
import { runStartupHealthCheck } from "./startup-health-check";
|
||||||
@ -598,23 +599,21 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
const settings = { ...this.settings };
|
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
||||||
const session = this.manager.getSession();
|
const payloadObj = buildBackupPayload({
|
||||||
const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
settings: { ...this.settings },
|
||||||
const payload = JSON.stringify({
|
|
||||||
version: 2,
|
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
settings,
|
session: this.manager.getSession(),
|
||||||
session,
|
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
|
||||||
history
|
|
||||||
});
|
});
|
||||||
this.audit("INFO", "Backup exportiert", {
|
this.audit("INFO", "Backup exportiert", {
|
||||||
historyEntries: history.length,
|
kind: payloadObj.kind,
|
||||||
sessionItems: Object.keys(session.items).length,
|
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
||||||
sessionPackages: Object.keys(session.packages).length
|
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||||||
|
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||||||
});
|
});
|
||||||
return encryptBackup(payload);
|
return encryptBackup(JSON.stringify(payloadObj));
|
||||||
}
|
}
|
||||||
|
|
||||||
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
||||||
@ -633,7 +632,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
return getSupportBundleDefaultFileName();
|
return getSupportBundleDefaultFileName();
|
||||||
}
|
}
|
||||||
|
|
||||||
public importBackup(data: Buffer): { restored: boolean; message: string } {
|
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
|
||||||
let parsed: Record<string, unknown>;
|
let parsed: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const json = decryptBackup(data);
|
const json = decryptBackup(data);
|
||||||
@ -643,12 +642,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
const json = data.toString("utf8");
|
const json = data.toString("utf8");
|
||||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
|
const plan = planBackupImport(parsed);
|
||||||
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
|
if (!plan.valid) {
|
||||||
|
return { restored: false, relaunch: false, message: plan.message };
|
||||||
}
|
}
|
||||||
|
const hasSession = plan.restoreDownloads;
|
||||||
|
|
||||||
const importedSettings = parsed.settings as AppSettings;
|
const importedSettings = parsed.settings as AppSettings;
|
||||||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||||||
@ -669,6 +670,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
|
|
||||||
|
// Settings-only backup: settings are already applied live (same path as the
|
||||||
|
// normal updateSettings flow). Do NOT stop the manager, wipe the session,
|
||||||
|
// block persistence or relaunch — the running queue stays untouched.
|
||||||
|
if (!hasSession) {
|
||||||
|
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||||
|
accountSummary: buildAccountSummary(this.settings)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
restored: true,
|
||||||
|
relaunch: false,
|
||||||
|
message: "Einstellungen wiederhergestellt"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.manager.stop();
|
this.manager.stop();
|
||||||
this.manager.abortAllPostProcessing();
|
this.manager.abortAllPostProcessing();
|
||||||
this.manager.clearPersistTimer();
|
this.manager.clearPersistTimer();
|
||||||
@ -698,7 +713,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
accountSummary: buildAccountSummary(this.settings)
|
||||||
});
|
});
|
||||||
return { restored: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionLogPath(): string | null {
|
public getSessionLogPath(): string | null {
|
||||||
|
|||||||
77
src/main/backup-payload.ts
Normal file
77
src/main/backup-payload.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||||
|
|
||||||
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
|
export interface BackupPayload {
|
||||||
|
version: 2;
|
||||||
|
kind: BackupKind;
|
||||||
|
appVersion: string;
|
||||||
|
exportedAt: string;
|
||||||
|
settings: AppSettings;
|
||||||
|
session?: SessionState;
|
||||||
|
history?: HistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildBackupInput {
|
||||||
|
settings: AppSettings;
|
||||||
|
appVersion: string;
|
||||||
|
exportedAt: string;
|
||||||
|
/** Only bundled when includeDownloads is true. */
|
||||||
|
session: SessionState;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||||||
|
* payload contains ONLY settings — no session, no history. The download list is
|
||||||
|
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||||||
|
* marker makes the import side unambiguous and survives hand-edited files.
|
||||||
|
*/
|
||||||
|
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||||
|
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||||||
|
const base: BackupPayload = {
|
||||||
|
version: 2,
|
||||||
|
kind: includeDownloads ? "full" : "settings-only",
|
||||||
|
appVersion: input.appVersion,
|
||||||
|
exportedAt: input.exportedAt,
|
||||||
|
settings: input.settings
|
||||||
|
};
|
||||||
|
if (includeDownloads) {
|
||||||
|
base.session = input.session;
|
||||||
|
base.history = input.history;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPlan {
|
||||||
|
valid: boolean;
|
||||||
|
/** Restore the download list (session + history) and relaunch. */
|
||||||
|
restoreDownloads: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide how to apply an imported backup based on what the FILE physically
|
||||||
|
* contains — NOT the local toggle. A backup without a session restores settings
|
||||||
|
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
||||||
|
* queue too. This way an old full backup still restores fully even if the local
|
||||||
|
* toggle is currently off, and a settings-only backup never disturbs a running
|
||||||
|
* queue.
|
||||||
|
*/
|
||||||
|
export function planBackupImport(parsed: unknown): ImportPlan {
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||||
|
}
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
if (!record.settings || typeof record.settings !== "object") {
|
||||||
|
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||||
|
}
|
||||||
|
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
restoreDownloads: hasSession,
|
||||||
|
message: hasSession
|
||||||
|
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||||||
|
: "Einstellungen wiederhergestellt"
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
|
backupIncludeDownloads: false,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalCompletedFilesAllTime: 0,
|
totalCompletedFilesAllTime: 0,
|
||||||
totalRuntimeAllTimeMs: 0,
|
totalRuntimeAllTimeMs: 0,
|
||||||
|
|||||||
@ -664,7 +664,10 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
const data = await fs.promises.readFile(filePath);
|
const data = await fs.promises.readFile(filePath);
|
||||||
const importResult = controller.importBackup(data);
|
const importResult = controller.importBackup(data);
|
||||||
if (importResult.restored) {
|
// 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.
|
||||||
|
if (importResult.restored && importResult.relaunch) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@ -456,6 +456,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
||||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||||
|
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
||||||
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
||||||
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
||||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||||
|
|||||||
@ -58,7 +58,7 @@ const api: ElectronApi = {
|
|||||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||||
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||||
|
import { pruneSelection } from "./selection";
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
||||||
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
||||||
@ -850,7 +851,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||||
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
@ -2109,6 +2110,13 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||||
|
|
||||||
|
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
||||||
|
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
|
||||||
|
// a stale id would otherwise inflate the selection count and the "(N)" labels.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
|
||||||
|
}, [snapshot.session.packages, snapshot.session.items]);
|
||||||
|
|
||||||
const hiddenPackageCount = shouldLimitPackageRendering
|
const hiddenPackageCount = shouldLimitPackageRendering
|
||||||
? Math.max(0, totalPackageCount - packages.length)
|
? Math.max(0, totalPackageCount - packages.length)
|
||||||
: 0;
|
: 0;
|
||||||
@ -3539,6 +3547,11 @@ export function App(): ReactElement {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
||||||
|
|
||||||
|
// Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard
|
||||||
|
// handler can select exactly what the user sees — not the whole unfiltered map.
|
||||||
|
const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds);
|
||||||
|
visibleOrderIdsRef.current = visibleOrderIds;
|
||||||
|
|
||||||
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
||||||
if (dragDidMoveRef.current) return;
|
if (dragDidMoveRef.current) return;
|
||||||
if (shiftKey && lastClickedIdRef.current) {
|
if (shiftKey && lastClickedIdRef.current) {
|
||||||
@ -3838,6 +3851,13 @@ export function App(): ReactElement {
|
|||||||
const result = await window.rd.importBackup();
|
const result = await window.rd.importBackup();
|
||||||
if (result.restored) {
|
if (result.restored) {
|
||||||
showToast(result.message, 4000);
|
showToast(result.message, 4000);
|
||||||
|
// A settings-only import applies live without a relaunch, so the editable
|
||||||
|
// settings form would otherwise keep showing the old values. Pull the
|
||||||
|
// fresh settings and re-seed the draft so the UI reflects the import.
|
||||||
|
if (!result.relaunch) {
|
||||||
|
const fresh = await window.rd.getSnapshot();
|
||||||
|
applyPersistedSettings(fresh.settings);
|
||||||
|
}
|
||||||
} else if (result.message !== "Abgebrochen") {
|
} else if (result.message !== "Abgebrochen") {
|
||||||
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
||||||
}
|
}
|
||||||
@ -3961,7 +3981,10 @@ export function App(): ReactElement {
|
|||||||
if (inInput) return;
|
if (inInput) return;
|
||||||
if (tabRef.current === "downloads") {
|
if (tabRef.current === "downloads") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
|
// 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));
|
||||||
} else if (tabRef.current === "history") {
|
} else if (tabRef.current === "history") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
|
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
|
||||||
@ -4881,6 +4904,7 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
||||||
const next = e.target.checked ? "light" : "dark";
|
const next = e.target.checked ? "light" : "dark";
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
@ -6335,7 +6359,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
switch (col) {
|
switch (col) {
|
||||||
case "name": return (
|
case "name": return (
|
||||||
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
|
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
|
||||||
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
|
<span
|
||||||
|
className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"}
|
||||||
|
title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined}
|
||||||
|
/>
|
||||||
{item.fileName}
|
{item.fileName}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -36,38 +36,26 @@ export function sortPackagesForDisplay(
|
|||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
|
const active: PackageEntry[] = [];
|
||||||
const rest: PackageEntry[] = [];
|
const rest: PackageEntry[] = [];
|
||||||
|
|
||||||
packages.forEach((pkg, index) => {
|
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||||
const items = pkg.itemIds
|
// their original (queue) order. Earlier this sorted the active group by live
|
||||||
.map((id) => itemsById[id])
|
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||||
.filter((item): item is DownloadItem => Boolean(item));
|
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||||
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
|
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||||
if (!hasActive) {
|
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||||
rest.push(pkg);
|
for (const pkg of packages) {
|
||||||
return;
|
const hasActive = pkg.itemIds.some((id) => {
|
||||||
}
|
const item = itemsById[id];
|
||||||
const completedRatio = items.length > 0
|
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||||
? items.filter((item) => item.status === "completed").length / items.length
|
});
|
||||||
: 0;
|
(hasActive ? active : rest).push(pkg);
|
||||||
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
}
|
||||||
active.push({ pkg, index, completedRatio, downloadedBytes });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active.length === 0 || active.length === packages.length) {
|
if (active.length === 0 || active.length === packages.length) {
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
active.sort((a, b) => {
|
return [...active, ...rest];
|
||||||
if (a.completedRatio !== b.completedRatio) {
|
|
||||||
return b.completedRatio - a.completedRatio;
|
|
||||||
}
|
|
||||||
if (a.downloadedBytes !== b.downloadedBytes) {
|
|
||||||
return b.downloadedBytes - a.downloadedBytes;
|
|
||||||
}
|
|
||||||
return a.index - b.index;
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...active.map((entry) => entry.pkg), ...rest];
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/renderer/selection.ts
Normal file
27
src/renderer/selection.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { SessionState } from "../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop selected ids whose package OR item no longer exists in the session.
|
||||||
|
* The selection set mixes package and item ids; when entries vanish (delta
|
||||||
|
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||||
|
* otherwise inflate the selection count and the "(N)" action labels and keep
|
||||||
|
* "multi" styling alive for ghosts.
|
||||||
|
*
|
||||||
|
* Returns the SAME set instance when nothing changed, so callers can use it
|
||||||
|
* directly as a React state updater without forcing a re-render.
|
||||||
|
*/
|
||||||
|
export function pruneSelection(
|
||||||
|
selected: ReadonlySet<string>,
|
||||||
|
session: Pick<SessionState, "packages" | "items">
|
||||||
|
): Set<string> {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
return selected as Set<string>;
|
||||||
|
}
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of selected) {
|
||||||
|
if (session.packages[id] || session.items[id]) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next.size === selected.size ? (selected as Set<string>) : next;
|
||||||
|
}
|
||||||
@ -2434,6 +2434,12 @@ td {
|
|||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
box-shadow: 0 0 4px #f59e0b80;
|
box-shadow: 0 0 4px #f59e0b80;
|
||||||
}
|
}
|
||||||
|
/* Reserve the dot's footprint even before a status exists, so the filename does
|
||||||
|
not shift ~14px right when the online/offline/checking dot first appears. */
|
||||||
|
.link-status-dot-empty {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.prio-high {
|
.prio-high {
|
||||||
color: #f59e0b !important;
|
color: #f59e0b !important;
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export interface ElectronApi {
|
|||||||
restart: () => Promise<void>;
|
restart: () => Promise<void>;
|
||||||
quit: () => Promise<void>;
|
quit: () => Promise<void>;
|
||||||
exportBackup: () => Promise<{ saved: boolean }>;
|
exportBackup: () => Promise<{ saved: boolean }>;
|
||||||
importBackup: () => Promise<{ restored: boolean; message: string }>;
|
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openAuditLog: () => Promise<void>;
|
openAuditLog: () => Promise<void>;
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export interface AppSettings {
|
|||||||
autoSkipExtracted: boolean;
|
autoSkipExtracted: boolean;
|
||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
|
backupIncludeDownloads: boolean;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalCompletedFilesAllTime: number;
|
totalCompletedFilesAllTime: number;
|
||||||
totalRuntimeAllTimeMs: number;
|
totalRuntimeAllTimeMs: number;
|
||||||
|
|||||||
81
tests/backup-payload.test.ts
Normal file
81
tests/backup-payload.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||||
|
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
||||||
|
|
||||||
|
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||||
|
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: SessionState = {
|
||||||
|
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
||||||
|
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||||
|
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||||
|
};
|
||||||
|
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||||
|
|
||||||
|
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
||||||
|
|
||||||
|
describe("buildBackupPayload — default is settings-only", () => {
|
||||||
|
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||||
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
|
expect(p.kind).toBe("settings-only");
|
||||||
|
expect(p.session).toBeUndefined();
|
||||||
|
expect(p.history).toBeUndefined();
|
||||||
|
expect(p.settings).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes session + history when backupIncludeDownloads is true", () => {
|
||||||
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||||
|
expect(p.kind).toBe("full");
|
||||||
|
expect(p.session).toBe(session);
|
||||||
|
expect(p.history).toBe(history);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing flag as settings-only (safe default)", () => {
|
||||||
|
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
||||||
|
expect(p.kind).toBe("settings-only");
|
||||||
|
expect(p.session).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
||||||
|
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
||||||
|
// so importing it keeps the toggle off.
|
||||||
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
|
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
||||||
|
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
||||||
|
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
||||||
|
expect(plan.valid).toBe(true);
|
||||||
|
expect(plan.restoreDownloads).toBe(false);
|
||||||
|
expect(plan.message).toMatch(/Einstellungen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full backup (with session) -> restore downloads + relaunch", () => {
|
||||||
|
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
||||||
|
expect(plan.valid).toBe(true);
|
||||||
|
expect(plan.restoreDownloads).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects payloads without settings", () => {
|
||||||
|
expect(planBackupImport({ session }).valid).toBe(false);
|
||||||
|
expect(planBackupImport(null).valid).toBe(false);
|
||||||
|
expect(planBackupImport("nope").valid).toBe(false);
|
||||||
|
expect(planBackupImport({}).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a settings-only export then import does NOT pull in the download list", () => {
|
||||||
|
// Build with toggle off, then plan the import of exactly that payload.
|
||||||
|
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
|
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||||
|
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a full export then import DOES restore the download list", () => {
|
||||||
|
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||||
|
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||||
|
expect(plan.restoreDownloads).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -44,23 +44,50 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("sortPackagesForDisplay", () => {
|
describe("sortPackagesForDisplay", () => {
|
||||||
it("moves active packages with more progress to the top when auto sort is enabled", () => {
|
it("floats active packages to the top, keeping queue order within each group", () => {
|
||||||
|
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
|
||||||
|
// their original queue order; pkg-c (queued only) sinks below.
|
||||||
const packages = [
|
const packages = [
|
||||||
createPackage("pkg-a", ["a1", "a2"]),
|
createPackage("pkg-a", ["a1", "a2"]),
|
||||||
createPackage("pkg-b", ["b1", "b2"]),
|
createPackage("pkg-c", ["c1"]),
|
||||||
createPackage("pkg-c", ["c1"])
|
createPackage("pkg-b", ["b1", "b2"])
|
||||||
];
|
];
|
||||||
const items: Record<string, DownloadItem> = {
|
const items: Record<string, DownloadItem> = {
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||||
|
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||||
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
|
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
|
||||||
|
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
|
||||||
|
const packages = [
|
||||||
|
createPackage("pkg-a", ["a1"]),
|
||||||
|
createPackage("pkg-b", ["b1"])
|
||||||
|
];
|
||||||
|
// Both active. pkg-b initially has more bytes than pkg-a.
|
||||||
|
const before: Record<string, DownloadItem> = {
|
||||||
|
a1: createItem("a1", "pkg-a", "downloading", 100),
|
||||||
|
b1: createItem("b1", "pkg-b", "downloading", 900)
|
||||||
|
};
|
||||||
|
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
|
||||||
|
|
||||||
|
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
|
||||||
|
// both are still active, so they keep queue order. (Old code swapped them.)
|
||||||
|
const after: Record<string, DownloadItem> = {
|
||||||
|
a1: createItem("a1", "pkg-a", "downloading", 5000),
|
||||||
|
b1: createItem("b1", "pkg-b", "downloading", 950)
|
||||||
|
};
|
||||||
|
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
|
||||||
|
|
||||||
|
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
|
||||||
|
expect(orderAfter).toEqual(orderBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps package order untouched when auto sort is disabled", () => {
|
it("keeps package order untouched when auto sort is disabled", () => {
|
||||||
|
|||||||
44
tests/selection.test.ts
Normal file
44
tests/selection.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pruneSelection } from "../src/renderer/selection";
|
||||||
|
import type { SessionState } from "../src/shared/types";
|
||||||
|
|
||||||
|
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
||||||
|
const packages: Record<string, never> = {};
|
||||||
|
const items: Record<string, never> = {};
|
||||||
|
for (const id of packageIds) packages[id] = {} as never;
|
||||||
|
for (const id of itemIds) items[id] = {} as never;
|
||||||
|
return { packages, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pruneSelection", () => {
|
||||||
|
it("drops ids whose package/item no longer exists", () => {
|
||||||
|
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
||||||
|
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||||
|
expect([...next].sort()).toEqual(["i1", "p1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
|
||||||
|
const sel = new Set(["p1", "i1"]);
|
||||||
|
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||||
|
expect(next).toBe(sel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same instance for an empty selection", () => {
|
||||||
|
const sel = new Set<string>();
|
||||||
|
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes everything when the whole session was swapped out", () => {
|
||||||
|
const sel = new Set(["p1", "i1"]);
|
||||||
|
const next = pruneSelection(sel, session([], []));
|
||||||
|
expect(next.size).toBe(0);
|
||||||
|
expect(next).not.toBe(sel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a mixed package+item selection when both survive", () => {
|
||||||
|
const sel = new Set(["p1", "p2", "i1"]);
|
||||||
|
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
|
||||||
|
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
||||||
|
expect(next).toBe(sel); // unchanged → same instance
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user