diff --git a/README.md b/README.md index 2b1dd10b..cb8c0c40 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ for a feature comparison and a settings import. | **PAR2 verify & repair** | Automatic verification after download. Damaged files are rebuilt from recovery blocks in pure Rust — no external par2 binary. | | **Archive extraction** | Automatic extraction of RAR, 7z, and ZIP archives after repair, multi-part sets included, with cleanup. | | **Overlapped pipeline** | Independent downloads, PAR2 repair, and extraction can progress together under explicit per-stage worker limits. | -| **Web UI** | Queue, history with per-download insights, lifetime statistics, live logs, drag-and-drop NZB upload, and selectable themes. | +| **Web UI** | Queue, history with per-download insights and undoable deletes, lifetime statistics, live logs, drag-and-drop NZB upload, and selectable themes. | | **Automation** | RSS feeds with regex rules, a watch folder, and a newsgroup browser with header search and threaded views. | | **Media library** | WebDAV library that streams files on demand, straight from Usenet, without downloading first. | | **REST API** | Full HTTP API with Swagger/OpenAPI documentation, plus a compatibility API for *arr applications. | diff --git a/apps/rustnzb/frontend/src/app/app.spec.ts b/apps/rustnzb/frontend/src/app/app.spec.ts index 6f3c933d..b45b8be1 100644 --- a/apps/rustnzb/frontend/src/app/app.spec.ts +++ b/apps/rustnzb/frontend/src/app/app.spec.ts @@ -25,7 +25,7 @@ function makeApp(postResult = new Subject()) { navigate: vi.fn(() => Promise.resolve(true)), }; const pauseState = new PauseStateService(); - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; const addNzb = new AddNzbService(); const app = new App( api as never, @@ -186,7 +186,7 @@ describe('quick speed limit', () => { expect(api.put).toHaveBeenCalledWith('/config/speed-limit', { speed_limit_bps: 5 * 1024 * 1024 }); expect(app.speedLimit()).toBe(5 * 1024 * 1024); expect(app.speedMenuOpen).toBe(false); - expect(snack.open).toHaveBeenCalledWith('Speed limit set to 5.0 MB/s', 'Close', { duration: 2000 }); + expect(snack.success).toHaveBeenCalledWith('Speed limit set to 5.0 MB/s'); }); it('converts a custom MB/s value and ignores empty input', () => { @@ -380,7 +380,7 @@ describe('drop .nzb files anywhere', () => { const { app, prefills, snack } = signedIn(); app.onWindowDrop(fileDrag('drop', [new File(['x'], 'movie.mkv')])); expect(prefills).toEqual([]); - expect(snack.open).toHaveBeenCalled(); + expect(snack.info).toHaveBeenCalled(); expect(app.announcer.assertive()).toContain('Only .nzb'); }); diff --git a/apps/rustnzb/frontend/src/app/app.ts b/apps/rustnzb/frontend/src/app/app.ts index 2c78795a..9ddf2341 100644 --- a/apps/rustnzb/frontend/src/app/app.ts +++ b/apps/rustnzb/frontend/src/app/app.ts @@ -31,8 +31,8 @@ import { BrowserNotificationsService } from './core/services/browser-notificatio import { ThemeService } from './core/services/theme.service'; import { AnnouncerService } from './core/services/announcer.service'; import { IconComponent } from './shared/icon.component'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { showHttpError } from './core/http/http-error'; +import { ToastService } from './core/services/toast.service'; export function isDemoPath(pathname: string): boolean { return pathname === '/demo' || pathname.startsWith('/demo/'); @@ -685,7 +685,7 @@ export class App implements OnInit, OnDestroy { public widthMode: WidthModeService, readonly theme: ThemeService, pauseState: PauseStateService, - private snack: MatSnackBar, + private toast: ToastService, private notifications: BrowserNotificationsService, readonly announcer: AnnouncerService, ) { @@ -801,12 +801,12 @@ export class App implements OnInit, OnDestroy { next: () => { this.speedLimit.set(bps); this.speedMenuOpen = false; - this.snack.open(bps > 0 ? `Speed limit set to ${formatSpeed(bps)}` : 'Speed limit off', 'Close', { - duration: 2000, - }); + this.toast.success( + bps > 0 ? `Speed limit set to ${formatSpeed(bps)}` : 'Speed limit off', + ); this.pollStatus(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to set speed limit'), + error: (err) => showHttpError(this.toast, err, 'Failed to set speed limit'), }); } @@ -864,9 +864,7 @@ export class App implements OnInit, OnDestroy { e.preventDefault(); const files = nzbUploadFiles(e.dataTransfer?.files); if (files.length === 0) { - this.snack.open('Only .nzb (or .zip/.rar/.7z/.gz) files can be added', 'Close', { - duration: 3000, - }); + this.toast.info('Only .nzb (or .zip/.rar/.7z/.gz) files can be added'); this.announcer.announceError('Only .nzb (or .zip/.rar/.7z/.gz) files can be added'); return; } @@ -910,7 +908,7 @@ export class App implements OnInit, OnDestroy { this.paused.set(wasPaused); this.pollStatus(); showHttpError( - this.snack, + this.toast, err, wasPaused ? 'Failed to resume downloads' : 'Failed to pause downloads', ); @@ -928,7 +926,7 @@ export class App implements OnInit, OnDestroy { pauseFor(secs: number): void { this.api.post(`/queue/pause-for?duration_secs=${secs}`).subscribe({ next: () => this.pollStatus(), - error: (err) => showHttpError(this.snack, err, 'Failed to pause downloads'), + error: (err) => showHttpError(this.toast, err, 'Failed to pause downloads'), }); this.pauseMenuOpen = false; } diff --git a/apps/rustnzb/frontend/src/app/core/services/api.service.ts b/apps/rustnzb/frontend/src/app/core/services/api.service.ts index c20e5965..82da923c 100644 --- a/apps/rustnzb/frontend/src/app/core/services/api.service.ts +++ b/apps/rustnzb/frontend/src/app/core/services/api.service.ts @@ -33,8 +33,11 @@ export class ApiService { return this.http.put(`${this.baseUrl}${path}`, body, { headers: this.headers() }); } - delete(path: string): Observable { - return this.http.delete(`${this.baseUrl}${path}`, { headers: this.headers() }); + delete(path: string, params?: Record): Observable { + return this.http.delete(`${this.baseUrl}${path}`, { + headers: this.headers(), + params, + }); } postForm(path: string, body: FormData): Observable { diff --git a/apps/rustnzb/frontend/src/app/core/services/toast.service.spec.ts b/apps/rustnzb/frontend/src/app/core/services/toast.service.spec.ts new file mode 100644 index 00000000..7aea5be4 --- /dev/null +++ b/apps/rustnzb/frontend/src/app/core/services/toast.service.spec.ts @@ -0,0 +1,52 @@ +import '@angular/compiler'; + +import { Subject } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { ToastService, UNDO_WINDOW_MS } from './toast.service'; + +function service() { + const ref = { onAction: vi.fn(() => new Subject()) }; + const snack = { open: vi.fn(() => ref) }; + return { toast: new ToastService(snack as never), snack, ref }; +} + +describe('ToastService', () => { + it('uses the house durations for success, info and error', () => { + const { toast, snack } = service(); + toast.success('Saved'); + toast.info('Somewhere'); + toast.error('Broken'); + expect(snack.open).toHaveBeenNthCalledWith(1, 'Saved', 'Close', { duration: 2000 }); + expect(snack.open).toHaveBeenNthCalledWith(2, 'Somewhere', 'Close', { duration: 3000 }); + expect(snack.open).toHaveBeenNthCalledWith(3, 'Broken', 'Close', { duration: 5000 }); + }); + + it('offers Undo for the full undo window and runs the callback on action', () => { + const { toast, snack, ref } = service(); + const onUndo = vi.fn(); + toast.undo('Deleted "Release"', onUndo); + expect(snack.open).toHaveBeenCalledWith('Deleted "Release"', 'Undo', { + duration: UNDO_WINDOW_MS, + }); + expect(onUndo).not.toHaveBeenCalled(); + const action = ref.onAction.mock.results[0].value as Subject; + action.next(); + expect(onUndo).toHaveBeenCalledTimes(1); + }); + + it('leaves the action in place when the toast times out or is dismissed', () => { + const { toast, ref } = service(); + const onUndo = vi.fn(); + toast.undo('Deleted', onUndo); + const action = ref.onAction.mock.results[0].value as Subject; + action.complete(); + expect(onUndo).not.toHaveBeenCalled(); + }); + + it('still exposes MatSnackBar open for showHttpError and custom actions', () => { + const { toast, snack } = service(); + toast.open('Failed', 'Close', { duration: 5000 }); + expect(snack.open).toHaveBeenCalledWith('Failed', 'Close', { duration: 5000 }); + }); +}); diff --git a/apps/rustnzb/frontend/src/app/core/services/toast.service.ts b/apps/rustnzb/frontend/src/app/core/services/toast.service.ts new file mode 100644 index 00000000..c51a7b08 --- /dev/null +++ b/apps/rustnzb/frontend/src/app/core/services/toast.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +/** How long a destructive action's undo stays on screen. */ +export const UNDO_WINDOW_MS = 10_000; + +/** + * The app's one way to speak through a snackbar. + * + * Results that arrive without a focus change also go through + * `AnnouncerService`; this service only owns the visual toast, so pages keep + * their spoken message next to the one a sighted user sees. + */ +@Injectable({ providedIn: 'root' }) +export class ToastService { + constructor(private readonly snack: MatSnackBar) {} + + /** A routine success. Two seconds is the house default. */ + success(message: string, duration = 2_000): void { + this.snack.open(message, 'Close', { duration }); + } + + /** An informational message that needs longer to read (a path, a hint). */ + info(message: string, duration = 3_000): void { + this.snack.open(message, 'Close', { duration }); + } + + /** A failure. Errors stay up longer than successes. */ + error(message: string, duration = 5_000): void { + this.snack.open(message, 'Close', { duration }); + } + + /** + * Report a destructive action that the server can reverse, with an `Undo` + * action. `onUndo` runs only when the user presses it; dismissing the toast + * or letting it time out leaves the action in place. + */ + undo( + message: string, + onUndo: () => void, + options: { duration?: number; undoLabel?: string } = {}, + ): void { + const ref = this.snack.open(message, options.undoLabel ?? 'Undo', { + duration: options.duration ?? UNDO_WINDOW_MS, + }); + ref.onAction().subscribe(() => onUndo()); + } + + /** + * MatSnackBar's own shape, kept so `showHttpError()` and callers that pass a + * custom action work unchanged. + */ + open(message: string, action?: string, config?: { duration?: number }): unknown { + return this.snack.open(message, action, config); + } +} diff --git a/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.spec.ts b/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.spec.ts index 852b3110..d58e9e72 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.spec.ts @@ -2,7 +2,7 @@ import '@angular/compiler'; import { TestBed } from '@angular/core/testing'; import { MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { ToastService } from '../../core/services/toast.service'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { of, throwError } from 'rxjs'; @@ -27,7 +27,13 @@ describe('GroupBrowserDialogComponent', () => { subscribe: ReturnType; unsubscribe: ReturnType; }; - let snack: { open: ReturnType }; + let snack: { + open: ReturnType; + success: ReturnType; + info: ReturnType; + error: ReturnType; + undo: ReturnType; + }; let dialogRef: { close: ReturnType }; beforeEach(() => { @@ -37,11 +43,11 @@ describe('GroupBrowserDialogComponent', () => { subscribe: vi.fn(() => of({})), unsubscribe: vi.fn(() => of({})), }; - snack = { open: vi.fn() }; + snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; dialogRef = { close: vi.fn() }; - // The component imports MatSnackBarModule, which supplies its own MatSnackBar, - // so overrideProvider is needed to replace it (a plain provider is shadowed). - TestBed.overrideProvider(MatSnackBar, { useValue: snack }); + // ToastService is providedIn: 'root', so overrideProvider is needed to + // replace it (a plain provider is shadowed). + TestBed.overrideProvider(ToastService, { useValue: snack }); TestBed.configureTestingModule({ imports: [GroupBrowserDialogComponent], providers: [ @@ -99,7 +105,7 @@ describe('GroupBrowserDialogComponent', () => { component.refresh(); expect(groupService.refresh).toHaveBeenCalledOnce(); - expect(snack.open).toHaveBeenCalledWith('Refreshed 2 groups', 'Close', { duration: 3000 }); + expect(snack.info).toHaveBeenCalledWith('Refreshed 2 groups'); expect(groupService.list).toHaveBeenCalledOnce(); expect(component.refreshing()).toBe(false); }); @@ -125,9 +131,7 @@ describe('GroupBrowserDialogComponent', () => { expect(groupService.subscribe).toHaveBeenCalledWith(1); expect(groupService.unsubscribe).not.toHaveBeenCalled(); expect(row.subscribed).toBe(true); - expect(snack.open).toHaveBeenCalledWith('Subscribed to alt.test.1', 'Close', { - duration: 2000, - }); + expect(snack.success).toHaveBeenCalledWith('Subscribed to alt.test.1'); expect(component.subPendingIds().has(1)).toBe(false); }); @@ -140,9 +144,7 @@ describe('GroupBrowserDialogComponent', () => { expect(groupService.unsubscribe).toHaveBeenCalledWith(3); expect(row.subscribed).toBe(false); - expect(snack.open).toHaveBeenCalledWith('Unsubscribed from alt.test.3', 'Close', { - duration: 2000, - }); + expect(snack.success).toHaveBeenCalledWith('Unsubscribed from alt.test.3'); }); it('keeps the star unchanged and clears the pending flag when a toggle fails', () => { diff --git a/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.ts b/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.ts index 47d25741..b0f14efd 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/group-browser-dialog.component.ts @@ -5,11 +5,12 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { GroupService } from '../../core/services/group.service'; import { GroupRow } from '../../core/models/group.model'; import { IconComponent } from '../../shared/icon.component'; import { showHttpError } from '../../core/http/http-error'; +import { ToastService } from '../../core/services/toast.service'; @Component({ selector: 'app-group-browser-dialog', @@ -78,7 +79,11 @@ export class GroupBrowserDialogComponent implements OnInit { refreshing = signal(false); subPendingIds = signal>(new Set()); - constructor(private svc: GroupService, private snack: MatSnackBar, private dialogRef: MatDialogRef) {} + constructor( + private svc: GroupService, + private toast: ToastService, + private dialogRef: MatDialogRef, + ) {} ngOnInit(): void { this.loadGroups(); } @@ -99,10 +104,10 @@ export class GroupBrowserDialogComponent implements OnInit { refresh(): void { this.refreshing.set(true); this.svc.refresh().subscribe({ - next: r => { this.refreshing.set(false); this.snack.open(r.message, 'Close', { duration: 3000 }); this.loadGroups(); }, + next: r => { this.refreshing.set(false); this.toast.info(r.message); this.loadGroups(); }, error: (e) => { this.refreshing.set(false); - showHttpError(this.snack, e, 'Refresh failed'); + showHttpError(this.toast, e, 'Refresh failed'); }, }); } @@ -122,16 +127,14 @@ export class GroupBrowserDialogComponent implements OnInit { next: () => { g.subscribed = !wasSubscribed; this.groups.set([...this.groups()]); - this.snack.open( + this.toast.success( wasSubscribed ? `Unsubscribed from ${g.name}` : `Subscribed to ${g.name}`, - 'Close', - { duration: 2000 }, ); clearPending(); }, error: (err) => { showHttpError( - this.snack, + this.toast, err, `Failed to ${wasSubscribed ? 'unsubscribe from' : 'subscribe to'} ${g.name}`, ); diff --git a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts index 1e8a5ac1..04b6eba1 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts @@ -27,7 +27,7 @@ function makeComponent(overrides: Record> = {}) markAllRead: vi.fn(() => of({ marked: 1 })), ...overrides, }; - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; const dialog = { open: vi.fn() }; return { component: new GroupsViewComponent(service as unknown as GroupService, snack as never, dialog as never), service, snack }; } diff --git a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts index 974666bf..acea1346 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts @@ -1,13 +1,14 @@ import { Component, OnDestroy, OnInit, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { GroupService } from '../../core/services/group.service'; import { GroupRow, HeaderRow } from '../../core/models/group.model'; import { GroupBrowserDialogComponent } from './group-browser-dialog.component'; import { IconComponent } from '../../shared/icon.component'; import { showHttpError } from '../../core/http/http-error'; +import { ToastService } from '../../core/services/toast.service'; import { formatBytes } from '../../core/format'; @Component({ @@ -424,7 +425,7 @@ export class GroupsViewComponent implements OnInit, OnDestroy { constructor( private svc: GroupService, - private snack: MatSnackBar, + private toast: ToastService, private dialog: MatDialog, ) {} @@ -498,10 +499,10 @@ export class GroupsViewComponent implements OnInit, OnDestroy { this.stopFetchPolling(); this.fetching.set(true); this.svc.fetchHeaders(g.id).subscribe({ - next: () => this.snack.open('Fetching headers…', 'Close', { duration: 2000 }), + next: () => this.toast.info('Fetching headers…'), error: (err) => { this.fetching.set(false); - showHttpError(this.snack, err, 'Failed to fetch headers'); + showHttpError(this.toast, err, 'Failed to fetch headers'); }, }); this.fetchPoll = setInterval(() => { @@ -522,10 +523,9 @@ export class GroupsViewComponent implements OnInit, OnDestroy { this.fetchGiveUp = setTimeout(() => { this.stopFetchPolling(); this.fetching.set(false); - this.snack.open( + this.toast.info( 'Header fetch is taking longer than expected — it may still finish in the background. Refresh to check.', - 'Close', - { duration: 6000 }, + 6000, ); }, 120_000); } @@ -544,9 +544,9 @@ export class GroupsViewComponent implements OnInit, OnDestroy { next: () => { this.loadHeaders(); this.loadGroups(); - this.snack.open('All marked read', 'Close', { duration: 2000 }); + this.toast.success('All marked read'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to mark all read'), + error: (err) => showHttpError(this.toast, err, 'Failed to mark all read'), }); } @@ -588,10 +588,10 @@ export class GroupsViewComponent implements OnInit, OnDestroy { if (!g || !this.selectedIds().length) return; this.svc.downloadSelected(g.id, this.selectedIds()).subscribe({ next: (r) => { - this.snack.open(r.message, 'Close', { duration: 3000 }); + this.toast.info(r.message); this.selectedIds.set([]); }, - error: (err) => showHttpError(this.snack, err, 'Download failed'), + error: (err) => showHttpError(this.toast, err, 'Download failed'), }); } diff --git a/apps/rustnzb/frontend/src/app/features/history/history-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/history/history-view.component.spec.ts index eaaf338f..ea1ca1c4 100644 --- a/apps/rustnzb/frontend/src/app/features/history/history-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/history/history-view.component.spec.ts @@ -5,9 +5,10 @@ import { firstValueFrom, of, Subject, throwError } from 'rxjs'; import { ApiService, POLL_TIMEOUT_MS } from '../../core/services/api.service'; import { HistoryEntry } from '../../core/models/queue.model'; -import { HistoryViewComponent } from './history-view.component'; +import { HistoryViewComponent, undoDuration } from './history-view.component'; import { AnnouncerService } from '../../core/services/announcer.service'; + function entry(overrides: Partial = {}): HistoryEntry { return { id: 'history-1', @@ -29,7 +30,13 @@ function entry(overrides: Partial = {}): HistoryEntry { describe('HistoryViewComponent', () => { let api: { get: ReturnType; post: ReturnType; delete: ReturnType }; - let snack: { open: ReturnType }; + let snack: { + open: ReturnType; + success: ReturnType; + info: ReturnType; + error: ReturnType; + undo: ReturnType; + }; let confirm: { confirm: ReturnType }; let announcer: AnnouncerService; let component: HistoryViewComponent; @@ -44,7 +51,9 @@ describe('HistoryViewComponent', () => { post: vi.fn(() => of({})), delete: vi.fn(() => of({})), }; - snack = { open: vi.fn() }; + // The component talks to ToastService; this mock is that service, so the + // undo callback can be captured and run the way a click would. + snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; confirm = { confirm: vi.fn(() => of(true)) }; announcer = new AnnouncerService(); component = new HistoryViewComponent( @@ -239,7 +248,7 @@ describe('HistoryViewComponent', () => { component.remove('id two'); component.addToMedia('id three'); expect(api.post).toHaveBeenCalledWith('/history/id one/retry'); - expect(api.delete).toHaveBeenCalledWith('/history/id two'); + expect(api.delete).toHaveBeenCalledWith('/history/id two', { trash: 'true' }); expect(api.post).toHaveBeenCalledWith('/dav/add?id=id three'); expect(load).toHaveBeenCalledTimes(2); }); @@ -369,19 +378,99 @@ describe('HistoryViewComponent', () => { expect(component.selectedIds().size).toBe(0); }); - it('bulk-deletes the selection through one request after confirmation', () => { + it('bulk-deletes the selection into the undo window, offering restore', () => { const load = vi.spyOn(component, 'load').mockImplementation(() => {}); - api.post.mockReturnValue(of({ status: true, succeeded: 2, failed: 0, failures: [] })); + api.post.mockReturnValue( + of({ + status: true, + succeeded: 2, + failed: 0, + failures: [], + trashed: { + status: true, + entries: [{ id: 'history-1' }, { id: 'history-2' }], + purge_after: '2026-07-10T00:00:10Z', + }, + }), + ); component.toggleSelected('history-1'); component.toggleSelected('history-2'); component.bulkDelete(); - expect(confirm.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); + expect(confirm.confirm).not.toHaveBeenCalled(); expect(api.post).toHaveBeenCalledWith('/history/bulk', { action: 'delete', ids: ['history-1', 'history-2'], + trash: true, }); expect(component.selectedIds().size).toBe(0); - expect(snack.open).toHaveBeenCalledWith('Deleted 2 entries', 'Close', { duration: 2000 }); + expect(snack.undo).toHaveBeenCalledWith('Deleted 2 entries', expect.any(Function), { + duration: 10000, + }); + expect(load).toHaveBeenCalled(); + + const undo = snack.undo.mock.calls[0][1] as () => void; + api.post.mockReturnValue(of({ status: true, restored: ['history-1', 'history-2'], failures: [] })); + undo(); + expect(api.post).toHaveBeenLastCalledWith('/history/restore', { + ids: ['history-1', 'history-2'], + }); + expect(snack.success).toHaveBeenCalledWith('Restored 2 entries'); + }); + + it('offers Undo for no longer than the server will honour it', () => { + api.get.mockImplementation((path: string) => + of(path === '/history' ? { entries: [entry()], total: 1 } : {}), + ); + component.load(); + api.delete.mockReturnValue( + of({ status: true, entries: [{ id: 'history-1' }], purge_after: '2026-07-10T00:00:06Z' }), + ); + + component.remove('history-1'); + + expect(snack.undo).toHaveBeenCalledWith('Deleted "Release.One"', expect.any(Function), { + duration: 6000, + }); + }); + + it('never offers a zero-length undo, and ignores an unparseable deadline', () => { + expect(undoDuration('2026-07-10T00:00:00Z', Date.parse('2026-07-10T00:00:05Z'))).toBe(1000); + expect(undoDuration('not a date')).toBeUndefined(); + expect(undoDuration(undefined)).toBeUndefined(); + }); + + it('reports an undo whose window has already closed', () => { + vi.spyOn(component, 'load').mockImplementation(() => {}); + api.post.mockReturnValue( + of({ status: false, restored: ['history-1'], failures: [{ id: 'history-2' }] }), + ); + component.remove('history-1'); + const undo = snack.undo.mock.calls[0][1] as () => void; + undo(); + expect(snack.error).toHaveBeenCalledWith('Restored 1, 1 could no longer be undone'); + }); + + it('deletes one row into the undo window and closes its detail pane', () => { + api.get.mockImplementation((path: string) => + of( + path === '/history' + ? { entries: [entry({ id: 'history-1', name: 'Release.One' })], total: 1 } + : {}, + ), + ); + component.load(); + expect(component.entries()[0]?.name).toBe('Release.One'); + const load = vi.spyOn(component, 'load').mockImplementation(() => {}); + api.delete.mockReturnValue( + of({ status: true, entries: [{ id: 'history-1' }], purge_after: '2026-07-10T00:00:10Z' }), + ); + component.selectedId.set('history-1'); + component.remove('history-1'); + expect(api.delete).toHaveBeenCalledWith('/history/history-1', { trash: 'true' }); + expect(component.selectedId()).toBeNull(); + expect(snack.undo).toHaveBeenCalledWith('Deleted "Release.One"', expect.any(Function), { + duration: 10000, + }); expect(load).toHaveBeenCalled(); }); @@ -432,7 +521,19 @@ describe('HistoryViewComponent', () => { component.filterTime = '30d'; component.filterCategory = 'tv'; component.nameFilter = ' release '; - api.post.mockReturnValue(of({ status: true, succeeded: 3, failed: 0, failures: [] })); + api.post.mockReturnValue( + of({ + status: true, + succeeded: 3, + failed: 0, + failures: [], + trashed: { + status: true, + entries: [{ id: 'f1' }, { id: 'f2' }, { id: 'f3' }], + purge_after: '2026-07-10T00:00:10Z', + }, + }), + ); component.clearByStatus('failed'); expect(confirm.confirm).toHaveBeenCalledWith( expect.objectContaining({ title: 'Clear 3 failed entries?', danger: true }), @@ -444,8 +545,11 @@ describe('HistoryViewComponent', () => { days: 30, category: 'tv', search: 'release', + trash: true, + }); + expect(snack.undo).toHaveBeenCalledWith('Cleared 3 failed entries', expect.any(Function), { + duration: 10000, }); - expect(snack.open).toHaveBeenCalledWith('Cleared 3 failed entries', 'Close', { duration: 2000 }); expect(load).toHaveBeenCalled(); expect(component.clearingStatus()).toBeNull(); }); @@ -459,6 +563,7 @@ describe('HistoryViewComponent', () => { action: 'delete', status: 'completed', ids: [], + trash: true, }); expect(load).toHaveBeenCalled(); }); diff --git a/apps/rustnzb/frontend/src/app/features/history/history-view.component.ts b/apps/rustnzb/frontend/src/app/features/history/history-view.component.ts index 7d8daf51..455862f3 100644 --- a/apps/rustnzb/frontend/src/app/features/history/history-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/history/history-view.component.ts @@ -2,13 +2,14 @@ import { Component, OnInit, OnDestroy, signal, computed } from '@angular/core'; import { EMPTY, Observable, expand, map, reduce, timeout } from 'rxjs'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { ApiService, POLL_TIMEOUT_MS, pageHidden } from '../../core/services/api.service'; import { HistoryEntry, StatusResponse } from '../../core/models/queue.model'; import { ConfirmService } from '../../shared/confirm.service'; import { IconComponent } from '../../shared/icon.component'; import { httpErrorMessage, showHttpError } from '../../core/http/http-error'; import { AnnouncerService } from '../../core/services/announcer.service'; +import { ToastService } from '../../core/services/toast.service'; type StatusFilter = 'all' | 'completed' | 'failed'; type TimeFilter = '7d' | '30d' | 'all'; @@ -37,14 +38,45 @@ interface BulkFailure { error?: { status?: number; error_kind?: string; human_readable?: string }; } +/** One entry moved into the undo window by a trashed delete. */ +interface TrashedEntry { + id: string; + name?: string; + status?: string; +} + +interface TrashResult { + status: boolean; + entries: TrashedEntry[]; + purge_after: string; +} + interface BulkResult { status: boolean; succeeded: number; failed: number; failures?: BulkFailure[]; + trashed?: TrashResult; +} + +interface RestoreResult { + status: boolean; + restored: string[]; + failures?: BulkFailure[]; } const PAGE_SIZE = 50; + +/** + * Keep the Undo action on screen no longer than the server will honour it. + * A missing or unparseable deadline falls back to the toast's own window. + */ +export function undoDuration(purgeAfter: string | undefined, now = Date.now()): number | undefined { + if (!purgeAfter) return undefined; + const deadline = Date.parse(purgeAfter); + if (Number.isNaN(deadline)) return undefined; + return Math.max(1_000, deadline - now); +} const EXPORT_PAGE_SIZE = 200; @Component({ @@ -420,7 +452,7 @@ export class HistoryViewComponent implements OnInit, OnDestroy { constructor( private api: ApiService, - private snack: MatSnackBar, + private toast: ToastService, private confirmSvc: ConfirmService, private announcer: AnnouncerService, ) {} @@ -429,7 +461,7 @@ export class HistoryViewComponent implements OnInit, OnDestroy { this.load(); this.api.get('/status').subscribe({ next: s => this.webdavEnabled.set(!!s.webdav_enabled), - error: err => showHttpError(this.snack, err, 'Failed to load WebDAV status'), + error: err => showHttpError(this.toast, err, 'Failed to load WebDAV status'), }); this.pollTimer = setInterval(() => this.poll(), 5000); } @@ -550,7 +582,7 @@ export class HistoryViewComponent implements OnInit, OnDestroy { }, error: err => { this.detailLoading.set(false); - showHttpError(this.snack, err, 'Failed to load download details'); + showHttpError(this.toast, err, 'Failed to load download details'); }, }); } @@ -665,18 +697,23 @@ export class HistoryViewComponent implements OnInit, OnDestroy { const failed = result?.failed ?? 0; if (failed > 0) { const message = `Failed to ${verb} ${failed} of ${count} entr${count === 1 ? 'y' : 'ies'}`; - showHttpError(this.snack, this.bulkFailure(result), message); + showHttpError(this.toast, this.bulkFailure(result), message); this.announcer.announceError(httpErrorMessage(this.bulkFailure(result), message)); } else { const done = this.bulkPast(verb, result?.succeeded ?? count); - this.snack.open(done, 'Close', { duration: 2000 }); - this.announcer.announce(done); + const trashed = result?.trashed?.entries?.map((e) => e.id) ?? []; + if (trashed.length > 0) { + this.offerUndo(done, trashed, result?.trashed?.purge_after); + } else { + this.toast.success(done); + this.announcer.announce(done); + } } this.load(); }, error: (err) => { this.bulkBusy.set(false); - showHttpError(this.snack, err, `Failed to ${verb} history entries`); + showHttpError(this.toast, err, `Failed to ${verb} history entries`); }, }); } @@ -708,23 +745,15 @@ export class HistoryViewComponent implements OnInit, OnDestroy { bulkDelete(): void { const ids = Array.from(this.selectedIds()); if (ids.length === 0 || this.bulkBusy()) return; - this.confirmSvc - .confirm({ - title: `Delete ${ids.length} history entr${ids.length === 1 ? 'y' : 'ies'}?`, - message: 'This removes the history entries. Downloaded files are kept.', - confirmLabel: 'Delete', - danger: true, - }) - .subscribe((ok) => { - if (!ok) return; - this.runBulk({ action: 'delete', ids }, 'delete', ids.length); - }); + // Undo is the confirmation: the rows are hidden, not destroyed, until the + // window closes, so an accidental click costs one click to reverse. + this.runBulk({ action: 'delete', ids, trash: true }, 'delete', ids.length); } /** * "Clear failed/completed": every entry of that status matching the - * current filters (not just the page). The server reports how many it - * removed; the button label carries the count the stat cards show. + * current filters (not just the page). The action is undoable, but it spans + * rows the user cannot see, so it still asks first. */ clearByStatus(status: 'completed' | 'failed'): void { if (this.clearingStatus() !== null) return; @@ -734,14 +763,19 @@ export class HistoryViewComponent implements OnInit, OnDestroy { title: `Clear ${count} ${status} entr${count === 1 ? 'y' : 'ies'}?`, message: `Every ${status} entry in the current time range${ this.narrowingFilters() ? ' and filter' : '' - } is removed. Downloaded files are kept.`, + } is removed. Downloaded files are kept; you can undo this for a few seconds.`, confirmLabel: `Clear ${status}`, danger: true, }) .subscribe((ok) => { if (!ok) return; this.clearingStatus.set(status); - const body: Record = { action: 'delete', status, ids: [] }; + const body: Record = { + action: 'delete', + status, + ids: [], + trash: true, + }; if (this.filterTime !== 'all') body['days'] = this.filterTime === '7d' ? 7 : 30; if (this.filterCategory) body['category'] = this.filterCategory; const search = this.nameFilter.trim(); @@ -751,13 +785,17 @@ export class HistoryViewComponent implements OnInit, OnDestroy { this.clearingStatus.set(null); const removed = result?.succeeded ?? 0; const done = `Cleared ${removed} ${status} entr${removed === 1 ? 'y' : 'ies'}`; - this.snack.open(done, 'Close', { duration: 2000 }); - this.announcer.announce(done); + const trashed = result?.trashed?.entries?.map((e) => e.id) ?? []; + if (trashed.length > 0) this.offerUndo(done, trashed, result?.trashed?.purge_after); + else { + this.toast.success(done); + this.announcer.announce(done); + } this.load(); }, error: (err) => { this.clearingStatus.set(null); - showHttpError(this.snack, err, `Failed to clear ${status} history`); + showHttpError(this.toast, err, `Failed to clear ${status} history`); }, }); }); @@ -767,23 +805,61 @@ export class HistoryViewComponent implements OnInit, OnDestroy { this.api.post(`/history/${id}/retry`).subscribe({ next: () => { this.load(); - this.snack.open('Retrying…', 'Close', { duration: 2000 }); + this.toast.success('Retrying…'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to retry job'), + error: (err) => showHttpError(this.toast, err, 'Failed to retry job'), }); } addToMedia(id: string): void { this.api.post(`/dav/add?id=${id}`).subscribe({ - next: () => this.snack.open('Queued for Media Library', 'Close', { duration: 3000 }), - error: (err) => showHttpError(this.snack, err, 'Failed to add to Media Library'), + next: () => this.toast.success('Queued for Media Library', 3000), + error: (err) => showHttpError(this.toast, err, 'Failed to add to Media Library'), }); } + /** Delete one row into the undo window, with a toast that can bring it back. */ remove(id: string): void { - this.api.delete(`/history/${id}`).subscribe({ - next: () => this.load(), - error: (err) => showHttpError(this.snack, err, 'Failed to delete history entry'), + const name = this.entries().find((e) => e.id === id)?.name ?? 'entry'; + this.api.delete(`/history/${id}`, { trash: 'true' }).subscribe({ + next: (result) => { + if (this.selectedId() === id) this.closeDetails(); + this.load(); + this.offerUndo(`Deleted "${name}"`, [id], result?.purge_after); + }, + error: (err) => showHttpError(this.toast, err, 'Failed to delete history entry'), + }); + } + + /** + * Show an undoable delete. Undo restores every id the server trashed; a + * partial restore (its window closed between delete and click) is reported + * rather than silently swallowed. + */ + private offerUndo(message: string, ids: string[], purgeAfter?: string): void { + this.announcer.announce(`${message}. Undo available.`); + const duration = undoDuration(purgeAfter); + if (duration === undefined) this.toast.undo(message, () => this.restore(ids)); + else this.toast.undo(message, () => this.restore(ids), { duration }); + } + + private restore(ids: string[]): void { + this.api.post('/history/restore', { ids }).subscribe({ + next: (result) => { + this.load(); + const restored = result?.restored?.length ?? 0; + const failed = result?.failures?.length ?? 0; + if (failed > 0) { + const message = `Restored ${restored}, ${failed} could no longer be undone`; + this.toast.error(message); + this.announcer.announceError(message); + } else { + const done = `Restored ${restored} entr${restored === 1 ? 'y' : 'ies'}`; + this.toast.success(done); + this.announcer.announce(done); + } + }, + error: (err) => showHttpError(this.toast, err, 'Failed to undo history delete'), }); } @@ -800,16 +876,16 @@ export class HistoryViewComponent implements OnInit, OnDestroy { this.api.delete('/history').subscribe({ next: () => { this.load(); - this.snack.open('History cleared', 'Close', { duration: 2000 }); + this.toast.success('History cleared'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to clear history'), + error: (err) => showHttpError(this.toast, err, 'Failed to clear history'), }); }); } openOutput(e: HistoryEntry): void { // No server endpoint for "reveal in file manager"; surface the path. - this.snack.open(e.output_dir || '(no output path recorded)', 'Close', { duration: 5000 }); + this.toast.info(e.output_dir || '(no output path recorded)', 5000); } /** Every entry matching the current filters, fetched page by page. */ @@ -838,7 +914,7 @@ export class HistoryViewComponent implements OnInit, OnDestroy { }, error: () => { this.exporting.set(false); - this.snack.open('Failed to export history', 'Close', { duration: 3000 }); + this.toast.error('Failed to export history', 3000); }, }); } diff --git a/apps/rustnzb/frontend/src/app/features/media/media-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/media/media-view.component.spec.ts index f2c2ae35..940ccf6c 100644 --- a/apps/rustnzb/frontend/src/app/features/media/media-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/media/media-view.component.spec.ts @@ -1,23 +1,23 @@ import '@angular/compiler'; import { HttpClient } from '@angular/common/http'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { TestBed } from '@angular/core/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { of, throwError } from 'rxjs'; import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; import { MediaViewComponent } from './media-view.component'; function makeComponent(status: unknown = { webdav_enabled: false }) { const api = { get: vi.fn(() => of(status)) }; const http = { request: vi.fn(() => of('')) }; - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; TestBed.configureTestingModule({ providers: [ { provide: ApiService, useValue: api }, { provide: HttpClient, useValue: http }, - { provide: MatSnackBar, useValue: snack }, + { provide: ToastService, useValue: snack }, ], }); const component = TestBed.runInInjectionContext(() => new MediaViewComponent()); diff --git a/apps/rustnzb/frontend/src/app/features/media/media-view.component.ts b/apps/rustnzb/frontend/src/app/features/media/media-view.component.ts index 5302257a..5164d188 100644 --- a/apps/rustnzb/frontend/src/app/features/media/media-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/media/media-view.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit, OnDestroy, signal, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; import { IconComponent } from '../../shared/icon.component'; interface DavFile { @@ -479,7 +480,7 @@ const AUDIO_EXTS = new Set(['mp3', 'flac', 'aac', 'ogg', 'wav', 'm4a', 'opus']); export class MediaViewComponent implements OnInit, OnDestroy { private http = inject(HttpClient); private api = inject(ApiService); - private snack = inject(MatSnackBar); + private toast = inject(ToastService); davBase = `${window.location.origin}/dav`; enabled = signal(true); @@ -649,16 +650,16 @@ export class MediaViewComponent implements OnInit, OnDestroy { copyBase(): void { navigator.clipboard.writeText(this.davBase).then( - () => this.snack.open('URL copied', 'Close', { duration: 2000 }), - () => this.snack.open(this.davBase, 'Close', { duration: 4000 }), + () => this.toast.success('URL copied'), + () => this.toast.info(this.davBase, 4000), ); } copyFileUrl(href: string): void { const url = this.fileUrl(href); navigator.clipboard.writeText(url).then( - () => this.snack.open('Stream URL copied', 'Close', { duration: 2000 }), - () => this.snack.open(url, 'Close', { duration: 4000 }), + () => this.toast.success('Stream URL copied'), + () => this.toast.info(url, 4000), ); } } diff --git a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts index 18075cff..4e892108 100644 --- a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts @@ -19,7 +19,13 @@ type ApiStub = { delete: ReturnType; }; type HttpStub = { post: ReturnType }; -type SnackBarStub = { open: ReturnType }; +type SnackBarStub = { + open: ReturnType; + success: ReturnType; + info: ReturnType; + error: ReturnType; + undo: ReturnType; +}; type ConfirmStub = { confirm: ReturnType }; type RouteStub = { data: Observable>; queryParamMap: Observable }; type RouterStub = { navigate: ReturnType }; @@ -71,7 +77,8 @@ function makeComponent( const http: HttpStub = { post: vi.fn(() => of({})), }; - const snackbarStub = snackBar ?? { open: vi.fn() }; + const snackbarStub = + snackBar ?? { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; const route: RouteStub = { data: of({}), queryParamMap: of(convertToParamMap({})), @@ -266,7 +273,7 @@ describe('QueueViewComponent', () => { component.pauseJob('job-1'); expect(loadQueue).toHaveBeenCalledTimes(1); - expect(snackBar.open).toHaveBeenCalledWith('Job paused', 'Close', { duration: 2500 }); + expect(snackBar.success).toHaveBeenCalledWith('Job paused', 2500); }); it('shows a failed status poll inline and clears it on the next success', () => { diff --git a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts index 76b2b87d..ca291db7 100644 --- a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { HttpClient } from '@angular/common/http'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Observable, Subscription, catchError, finalize, forkJoin, map, of, timeout } from 'rxjs'; import { ApiService, POLL_TIMEOUT_MS, pageHidden } from '../../core/services/api.service'; import { AnnouncerService } from '../../core/services/announcer.service'; @@ -15,6 +15,7 @@ import { HistoryViewComponent } from '../history/history-view.component'; import { ConfirmService } from '../../shared/confirm.service'; import { IconComponent } from '../../shared/icon.component'; import { httpErrorMessage, isAuthHandledError, showHttpError } from '../../core/http/http-error'; +import { ToastService } from '../../core/services/toast.service'; interface CategoryConfig { name: string; @@ -1202,7 +1203,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { constructor( private api: ApiService, private http: HttpClient, - private snackBar: MatSnackBar, + private toast: ToastService, private addNzbService: AddNzbService, private route: ActivatedRoute, private router: Router, @@ -1309,7 +1310,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { loadCategories(): void { this.api.get('/config/categories').subscribe({ next: (cats) => this.categories.set(cats), - error: (err) => showHttpError(this.snackBar, err, 'Failed to load categories'), + error: (err) => showHttpError(this.toast, err, 'Failed to load categories'), }); } @@ -1319,7 +1320,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { this.servers.set(srvs); this.serversLoaded.set(true); }, - error: (err) => showHttpError(this.snackBar, err, 'Failed to load servers'), + error: (err) => showHttpError(this.toast, err, 'Failed to load servers'), }); } @@ -1533,7 +1534,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { next: () => { const count = this.selectedFiles.length; const message = `${count} NZB${count > 1 ? 's' : ''} added to queue`; - this.snackBar.open(message, 'Close', { duration: 3000 }); + this.toast.info(message, 3000); this.announcer.announce(message); this.selectedFiles = []; this.uploading = false; @@ -1543,7 +1544,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { error: (err) => { this.uploading = false; const fallback = err?.status === 413 ? 'Upload too large' : 'Upload failed'; - showHttpError(this.snackBar, err, fallback); + showHttpError(this.toast, err, fallback); this.announcer.announceError(httpErrorMessage(err, fallback)); }, }); @@ -1557,7 +1558,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { if (this.addPriority !== 1) body.priority = this.addPriority; this.api.post('/queue/add-url', body).subscribe({ next: () => { - this.snackBar.open('NZB added from URL', 'Close', { duration: 3000 }); + this.toast.info('NZB added from URL', 3000); this.announcer.announce('NZB added from URL'); this.addUrl = ''; this.uploading = false; @@ -1566,7 +1567,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { }, error: (err) => { this.uploading = false; - showHttpError(this.snackBar, err, 'Failed to add NZB from URL'); + showHttpError(this.toast, err, 'Failed to add NZB from URL'); this.announcer.announceError(httpErrorMessage(err, 'Failed to add NZB from URL')); }, }); @@ -1601,13 +1602,13 @@ export class QueueViewComponent implements OnInit, OnDestroy { .subscribe({ next: () => { if (successMessage) { - this.snackBar.open(successMessage, 'Close', { duration: 2500 }); + this.toast.success(successMessage, 2500); this.announcer.announce(successMessage); } this.loadQueue(); }, error: (err) => { - showHttpError(this.snackBar, err, failureMessage); + showHttpError(this.toast, err, failureMessage); this.announcer.announceError(httpErrorMessage(err, failureMessage)); this.loadQueue(); }, @@ -1640,7 +1641,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { this.api.put(`/queue/${job.id}/priority`, { priority }).subscribe({ next: () => this.loadQueue(), error: (err) => { - showHttpError(this.snackBar, err, 'Failed to change priority'); + showHttpError(this.toast, err, 'Failed to change priority'); this.loadQueue(); }, }); @@ -1734,7 +1735,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { error: (err) => { this.reorderPending.set(false); this.jobs.set(previousJobs); - showHttpError(this.snackBar, err, 'Unable to reorder queue'); + showHttpError(this.toast, err, 'Unable to reorder queue'); }, }); } @@ -1821,7 +1822,7 @@ export class QueueViewComponent implements OnInit, OnDestroy { const failures = results.filter((r) => r !== null && !isAuthHandledError(r)); if (failures.length > 0) { const message = `Failed to ${verb} ${failures.length} of ${ids.length} job(s)`; - showHttpError(this.snackBar, failures[0], message); + showHttpError(this.toast, failures[0], message); this.announcer.announceError(httpErrorMessage(failures[0], message)); } else { const past = verb === 'remove' ? 'Removed' : verb === 'resume' ? 'Started' : 'Paused'; diff --git a/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.spec.ts index ff91e085..90c217b2 100644 --- a/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.spec.ts @@ -12,7 +12,7 @@ function createComponent(overrides: Record> = { get: vi.fn(() => of([])), post: vi.fn(() => of({})), put: vi.fn(() => of({})), delete: vi.fn(() => of({})), ...overrides, }; - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; const confirm = { confirm: vi.fn(() => of(true)) }; return { component: new RssViewComponent(api as unknown as ApiService, snack as never, confirm as never), api, snack, confirm }; } @@ -60,7 +60,7 @@ describe('RssViewComponent', () => { component.showAddFeed(); component.saveFeed(); expect(api.post).not.toHaveBeenCalled(); - expect(snack.open).toHaveBeenCalledWith('Name and URL are required', 'Close', { duration: 3000 }); + expect(snack.info).toHaveBeenCalledWith('Name and URL are required'); component.feedForm = { ...component.feedForm, name: 'daily', url: 'https://example.test' }; component.saveFeed(); diff --git a/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.ts b/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.ts index bc0064ed..aa765ffa 100644 --- a/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/rss/rss-view.component.ts @@ -2,10 +2,11 @@ import { Component, OnInit, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { formatBytes } from '../../core/format'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { ApiService } from '../../core/services/api.service'; import { ConfirmService } from '../../shared/confirm.service'; import { httpErrorMessage, isAuthHandledError, showHttpError } from '../../core/http/http-error'; +import { ToastService } from '../../core/services/toast.service'; interface RssFeed { name: string; url: string; poll_interval_secs: number; category: string | null; @@ -325,7 +326,7 @@ export class RssViewComponent implements OnInit { constructor( private api: ApiService, - private snack: MatSnackBar, + private toast: ToastService, private confirmSvc: ConfirmService, ) {} @@ -334,15 +335,15 @@ export class RssViewComponent implements OnInit { loadAll(): void { this.api.get('/config/rss-feeds').subscribe({ next: feeds => this.feeds.set(feeds), - error: err => showHttpError(this.snack, err, 'Failed to load RSS feeds'), + error: err => showHttpError(this.toast, err, 'Failed to load RSS feeds'), }); this.api.get('/rss/rules').subscribe({ next: rules => this.rules.set(rules), - error: err => showHttpError(this.snack, err, 'Failed to load RSS rules'), + error: err => showHttpError(this.toast, err, 'Failed to load RSS rules'), }); this.api.get('/rss/items').subscribe({ next: items => this.items.set(items), - error: err => showHttpError(this.snack, err, 'Failed to load RSS items'), + error: err => showHttpError(this.toast, err, 'Failed to load RSS items'), }); } @@ -387,7 +388,7 @@ export class RssViewComponent implements OnInit { auto_download: this.feedForm.auto_download, }; if (!body.name || !body.url) { - this.snack.open('Name and URL are required', 'Close', { duration: 3000 }); + this.toast.info('Name and URL are required'); return; } const editing = this.editingFeedName(); @@ -396,13 +397,13 @@ export class RssViewComponent implements OnInit { : this.api.post('/config/rss-feeds', body); req.subscribe({ next: () => { - this.snack.open(editing ? 'Feed updated' : 'Feed added', 'Close', { duration: 2000 }); + this.toast.success(editing ? 'Feed updated' : 'Feed added'); this.cancelFeedForm(); this.loadAll(); }, error: (err) => { if (!isAuthHandledError(err)) this.feedError.set(httpErrorMessage(err, 'Failed to save feed')); - showHttpError(this.snack, err, 'Failed to save feed'); + showHttpError(this.toast, err, 'Failed to save feed'); }, }); } @@ -419,10 +420,10 @@ export class RssViewComponent implements OnInit { if (!ok) return; this.api.delete(`/config/rss-feeds/${encodeURIComponent(name)}`).subscribe({ next: () => { - this.snack.open('Feed deleted', 'Close', { duration: 2000 }); + this.toast.success('Feed deleted'); this.loadAll(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to delete feed'), + error: (err) => showHttpError(this.toast, err, 'Failed to delete feed'), }); }); } @@ -470,7 +471,7 @@ export class RssViewComponent implements OnInit { feed_names: feedNames, }; if (!body.name || !body.match_regex) { - this.snack.open('Name and regex are required', 'Close', { duration: 3000 }); + this.toast.info('Name and regex are required'); return; } const editing = this.editingRuleId(); @@ -479,13 +480,13 @@ export class RssViewComponent implements OnInit { : this.api.post('/rss/rules', body); req.subscribe({ next: () => { - this.snack.open(editing ? 'Rule updated' : 'Rule added', 'Close', { duration: 2000 }); + this.toast.success(editing ? 'Rule updated' : 'Rule added'); this.cancelRuleForm(); this.loadAll(); }, error: (err) => { if (!isAuthHandledError(err)) this.ruleError.set(httpErrorMessage(err, 'Failed to save rule')); - showHttpError(this.snack, err, 'Failed to save rule'); + showHttpError(this.toast, err, 'Failed to save rule'); }, }); } @@ -502,10 +503,10 @@ export class RssViewComponent implements OnInit { if (!ok) return; this.api.delete(`/rss/rules/${id}`).subscribe({ next: () => { - this.snack.open('Rule deleted', 'Close', { duration: 2000 }); + this.toast.success('Rule deleted'); this.loadAll(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to delete rule'), + error: (err) => showHttpError(this.toast, err, 'Failed to delete rule'), }); }); } @@ -515,10 +516,10 @@ export class RssViewComponent implements OnInit { downloadItem(id: string): void { this.api.post(`/rss/items/${id}/download`).subscribe({ next: () => { - this.snack.open('Added to queue', 'Close', { duration: 2000 }); + this.toast.success('Added to queue'); this.loadAll(); }, - error: (err) => showHttpError(this.snack, err, 'Download failed'), + error: (err) => showHttpError(this.toast, err, 'Download failed'), }); } diff --git a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts index 01b3103b..f6239749 100644 --- a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts @@ -29,7 +29,7 @@ function makeComponent(overrides: Record> = {}) delete: vi.fn(() => of({})), ...overrides, }; - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; const confirm = { confirm: vi.fn(() => of(true)) }; const theme = { set: vi.fn() }; const notifications = { enabled: vi.fn(() => false), supported: true, permission: vi.fn(() => 'default'), setEnabled: vi.fn() }; @@ -62,7 +62,7 @@ describe('SettingsViewComponent', () => { component.addServer(); component.saveServer(); expect(api.post).not.toHaveBeenCalled(); - expect(snack.open).toHaveBeenCalledWith('Host is required', 'Close', { duration: 3000 }); + expect(snack.info).toHaveBeenCalledWith('Host is required'); component.editingServer = { ...component.editingServer!, host: 'news.example', username: '', password: '' }; component.saveServer(); diff --git a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts index 9503037d..61de7874 100644 --- a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts @@ -2,7 +2,7 @@ import { Component, HostListener, OnInit, signal, computed } from '@angular/core import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { ApiService } from '../../core/services/api.service'; import { formatBytes } from '../../core/format'; import { StatusResponse } from '../../core/models/queue.model'; @@ -11,6 +11,7 @@ import { IconComponent } from '../../shared/icon.component'; import { AppTheme, ThemeService } from '../../core/services/theme.service'; import { BrowserNotificationsService } from '../../core/services/browser-notifications.service'; import { httpErrorMessage, showHttpError } from '../../core/http/http-error'; +import { ToastService } from '../../core/services/toast.service'; import { HasUnsavedChanges } from '../../core/guards/unsaved-changes.guard'; interface ServerConfig { @@ -1619,7 +1620,7 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { constructor( private api: ApiService, - private snack: MatSnackBar, + private toast: ToastService, private confirmSvc: ConfirmService, public readonly theme: ThemeService, public readonly notifications: BrowserNotificationsService, @@ -1687,9 +1688,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.api.put(path, body).subscribe({ next: () => { this.baselines[key] = sent; - this.snack.open(saved, 'Close', { duration: 2000 }); + this.toast.success(saved); }, - error: (err) => showHttpError(this.snack, err, fallback), + error: (err) => showHttpError(this.toast, err, fallback), }); } @@ -1716,14 +1717,14 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.status.set(s); if (s.webdav_available) this.loadDavConfig(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to load server status'), + error: (err) => showHttpError(this.toast, err, 'Failed to load server status'), }); } loadDirs(): void { this.api.get<{ general: GeneralDirs }>('/config').subscribe({ next: (cfg) => this.dirs.set(cfg.general), - error: (err) => showHttpError(this.snack, err, 'Failed to load directory paths'), + error: (err) => showHttpError(this.toast, err, 'Failed to load directory paths'), }); } @@ -1743,7 +1744,7 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { for (const s of r) map[s.server_id] = s; this.serverStats.set(map); }, - error: (err) => showHttpError(this.snack, err, 'Failed to load server stats'), + error: (err) => showHttpError(this.toast, err, 'Failed to load server stats'), }); } @@ -1793,7 +1794,7 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { if (!this.editingServer || !this.canSave('servers')) return; const server = { ...this.editingServer }; if (!server.host.trim()) { - this.snack.open('Host is required', 'Close', { duration: 3000 }); + this.toast.info('Host is required'); return; } if (!server.username) server.username = null; @@ -1806,29 +1807,29 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { if (this.editingServerId) { this.api.put(`/config/servers/${this.editingServerId}`, server).subscribe({ next: () => { - this.snack.open('Server updated', 'Close', { duration: 2000 }); + this.toast.success('Server updated'); this.cancelServerEdit(); this.loadServers(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to update server'), + error: (err) => showHttpError(this.toast, err, 'Failed to update server'), }); } else { server.id = ''; this.api.post('/config/servers', server).subscribe({ next: () => { - this.snack.open('Server added', 'Close', { duration: 2000 }); + this.toast.success('Server added'); this.cancelServerEdit(); this.loadServers(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to add server'), + error: (err) => showHttpError(this.toast, err, 'Failed to add server'), }); } } testServer(id: string): void { this.api.post<{ success: boolean; message: string }>(`/config/servers/${id}/test`).subscribe({ - next: (r) => this.snack.open(r.message, 'Close', { duration: 3000 }), - error: (err) => showHttpError(this.snack, err, 'Test failed'), + next: (r) => this.toast.info(r.message), + error: (err) => showHttpError(this.toast, err, 'Test failed'), }); } @@ -1840,12 +1841,12 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { if (!body.username) body.username = null; if (this.editingServerId && !body.password) body.password = '********'; else if (!body.password) body.password = null; - this.snack.open('Testing…', '', { duration: 1500 }); + this.toast.open('Testing…', '', { duration: 1500 }); this.api .post<{ success: boolean; message: string }>(`/config/servers/test-config`, body) .subscribe({ - next: (r) => this.snack.open(r.message, 'Close', { duration: 4000 }), - error: (err) => showHttpError(this.snack, err, 'Test failed'), + next: (r) => this.toast.info(r.message, 4000), + error: (err) => showHttpError(this.toast, err, 'Test failed'), }); } @@ -1856,11 +1857,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.api.put(`/config/servers/${s.id}`, updated).subscribe({ next: () => { this.loadServers(); - this.snack.open(updated.enabled ? 'Server enabled' : 'Server disabled', 'Close', { - duration: 2000, - }); + this.toast.success(updated.enabled ? 'Server enabled' : 'Server disabled'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to update server'), + error: (err) => showHttpError(this.toast, err, 'Failed to update server'), }); } @@ -1877,9 +1876,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.api.delete(`/config/servers/${id}`).subscribe({ next: () => { this.loadServers(); - this.snack.open('Server removed', 'Close', { duration: 2000 }); + this.toast.success('Server removed'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to delete server'), + error: (err) => showHttpError(this.toast, err, 'Failed to delete server'), }); }); } @@ -1916,20 +1915,20 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { const encoded = encodeURIComponent(this.editingCategoryOriginalName); this.api.put(`/config/categories/${encoded}`, cat).subscribe({ next: () => { - this.snack.open('Category updated', 'Close', { duration: 2000 }); + this.toast.success('Category updated'); this.cancelCategoryEdit(); this.loadCategories(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to update category'), + error: (err) => showHttpError(this.toast, err, 'Failed to update category'), }); } else { this.api.post('/config/categories', cat).subscribe({ next: () => { - this.snack.open('Category added', 'Close', { duration: 2000 }); + this.toast.success('Category added'); this.cancelCategoryEdit(); this.loadCategories(); }, - error: (err) => showHttpError(this.snack, err, 'Failed to add category'), + error: (err) => showHttpError(this.toast, err, 'Failed to add category'), }); } } @@ -1948,9 +1947,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.api.delete(`/config/categories/${encoded}`).subscribe({ next: () => { this.loadCategories(); - this.snack.open('Category removed', 'Close', { duration: 2000 }); + this.toast.success('Category removed'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to delete category'), + error: (err) => showHttpError(this.toast, err, 'Failed to delete category'), }); }); } @@ -1977,7 +1976,7 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { const on = await this.notifications.setEnabled(input.checked); if (input.checked && !on) { input.checked = false; - this.snack.open( + this.toast.open( this.notifications.supported ? 'Notifications are blocked. Allow them for this site in the browser settings.' : 'This browser does not support notifications.', @@ -2059,9 +2058,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { next: (response) => { this.sabApiKey = response.api_key; this.showSabApiKey = true; - this.snack.open(`SABnzbd API key ${action.toLowerCase()}d`, 'Close', { duration: 2500 }); + this.toast.success(`SABnzbd API key ${action.toLowerCase()}d`, 2500); }, - error: (err) => showHttpError(this.snack, err, `Failed to ${action.toLowerCase()} SABnzbd API key`), + error: (err) => showHttpError(this.toast, err, `Failed to ${action.toLowerCase()} SABnzbd API key`), }); }); } @@ -2099,13 +2098,9 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { .subscribe({ next: (r) => { this.applyDebugLogging(r); - this.snack.open( - this.debugLogging ? 'Debug logging on' : 'Debug logging off', - 'Close', - { duration: 2000 }, - ); + this.toast.success(this.debugLogging ? 'Debug logging on' : 'Debug logging off'); }, - error: (err) => showHttpError(this.snack, err, 'Failed to change debug logging'), + error: (err) => showHttpError(this.toast, err, 'Failed to change debug logging'), }); } @@ -2168,15 +2163,14 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.api.put('/config/dav', payload).subscribe({ next: () => { this.baselines.dav = sent; - this.snack.open( + this.toast.success( restartRequired ? 'Media Library settings saved. Restart required.' : 'Media Library settings saved', - 'Close', - { duration: 2500 }, + 2500, ); }, - error: (err) => showHttpError(this.snack, err, 'Failed to save Media Library settings'), + error: (err) => showHttpError(this.toast, err, 'Failed to save Media Library settings'), }); } @@ -2221,8 +2215,8 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { copy(text: string): void { navigator.clipboard.writeText(text).then( - () => this.snack.open('Copied', 'Close', { duration: 1500 }), - () => this.snack.open('Copy failed', 'Close', { duration: 2000 }), + () => this.toast.success('Copied', 1500), + () => this.toast.error('Copy failed'), ); } } diff --git a/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.spec.ts b/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.spec.ts index 79770353..8147c74f 100644 --- a/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.spec.ts @@ -9,7 +9,7 @@ import { WelcomeComponent } from './welcome.component'; function makeComponent(overrides: Record> = {}) { const api = { get: vi.fn(() => of({ has_servers: false })), post: vi.fn(() => of({})), postForm: vi.fn(() => of({})), ...overrides }; const router = { navigate: vi.fn(() => Promise.resolve(true)) }; - const snack = { open: vi.fn() }; + const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() }; return { component: new WelcomeComponent(api as unknown as ApiService, router as never, snack as never), api, router, snack }; } diff --git a/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.ts b/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.ts index 29011f89..bb189140 100644 --- a/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.ts +++ b/apps/rustnzb/frontend/src/app/features/welcome/welcome.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { ToastService } from '../../core/services/toast.service'; import { httpErrorDetail, showHttpError } from '../../core/http/http-error'; type Step = 'landing' | 'connect' | 'preview' | 'applying'; @@ -418,7 +418,7 @@ export class WelcomeComponent implements OnInit { constructor( private api: ApiService, private router: Router, - private snack: MatSnackBar, + private toast: ToastService, ) {} ngOnInit(): void { @@ -428,7 +428,7 @@ export class WelcomeComponent implements OnInit { this.router.navigate(['/downloads']); } }, - error: (err) => showHttpError(this.snack, err, "Couldn't check setup status"), + error: (err) => showHttpError(this.toast, err, "Couldn't check setup status"), }); } diff --git a/apps/rustnzb/src/handlers.rs b/apps/rustnzb/src/handlers.rs index fbb09462..ad417056 100644 --- a/apps/rustnzb/src/handlers.rs +++ b/apps/rustnzb/src/handlers.rs @@ -84,7 +84,7 @@ pub struct HistoryQuery { pub search: Option, /// Only entries completed within the last N days. Also bounds `stats`. /// Values above [`MAX_HISTORY_DAYS`] are rejected: subtracting the span - /// from `Utc::now()` overflows chrono's DateTime range (about + /// from `chrono::Utc::now()` overflows chrono's DateTime range (about /// ±262 000 years), which panics. pub days: Option, /// Include the detail-only `stages` and `server_stats` arrays on every @@ -1036,13 +1036,143 @@ pub async fn h_history_get( Ok(Json(entry.into())) } +/// How long a trashed history entry stays restorable. Long enough for the +/// toast that offers the undo to be read and acted on. +pub const HISTORY_UNDO_WINDOW: chrono::Duration = chrono::Duration::seconds(10); + +/// Query for history deletions. A delete is a hard delete unless the caller +/// asks for the undo window with `trash=true`; SAB and *arr clients depend on +/// the old behaviour, and so does `clear all`. +#[derive(Deserialize, Default)] +pub struct HistoryDeleteQuery { + #[serde(default)] + pub trash: bool, +} + +/// What a trashed deletion returns: the entries moved into the undo window +/// and when that window closes. A caller restores them with +/// `POST /api/history/restore`, so it must keep the ids. +#[derive(Serialize)] +pub struct HistoryTrashResponse { + pub status: bool, + /// Entries actually trashed, in request order. + pub entries: Vec, + /// RFC 3339 instant after which a restore is refused and the next purge + /// sweep removes the rows. + pub purge_after: String, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum HistoryDeleteResponse { + Trashed(HistoryTrashResponse), + Removed(SimpleResponse), +} + +/// The list-row shape for a trashed entry. It keeps the fields an undo needs +/// to put the row back in view, without loading its blobs. +#[derive(Serialize)] +pub struct TrashedHistoryEntry { + pub id: String, + pub name: String, + pub status: JobStatus, +} + +impl From for TrashedHistoryEntry { + fn from(entry: HistorySummary) -> Self { + Self { + id: entry.id, + name: entry.name, + status: entry.status, + } + } +} + +fn trash_response( + entries: Vec, + deadline: chrono::DateTime, +) -> HistoryTrashResponse { + HistoryTrashResponse { + status: true, + entries: entries.into_iter().map(Into::into).collect(), + purge_after: deadline.to_rfc3339(), + } +} + /// DELETE /api/history/{id} -- Remove a history entry. +/// +/// `?trash=true` moves it into the undo window instead of deleting it, and +/// returns its summary plus `purge_after`. pub async fn h_history_delete( State(state): State>, Path(id): Path, -) -> Result, ApiError> { - qm_blocking(&state, move |qm| qm.history_remove(&id)).await?; - Ok(Json(SimpleResponse { status: true })) + Query(q): Query, +) -> Result, ApiError> { + if !q.trash { + qm_blocking(&state, move |qm| qm.history_remove(&id)).await?; + return Ok(Json(HistoryDeleteResponse::Removed(SimpleResponse { + status: true, + }))); + } + let deadline = chrono::Utc::now() + HISTORY_UNDO_WINDOW; + let trashed = qm_blocking(&state, move |qm| qm.history_trash(&[id], deadline)).await?; + if trashed.is_empty() { + return Err(ApiError::not_found("History entry not found")); + } + Ok(Json(HistoryDeleteResponse::Trashed(trash_response( + trashed, deadline, + )))) +} + +#[derive(Deserialize)] +pub struct HistoryRestoreBody { + pub ids: Vec, +} + +#[derive(Serialize)] +pub struct HistoryRestoreResponse { + pub status: bool, + /// Ids brought back, in request order. + pub restored: Vec, + /// One entry per id that could not be restored: unknown, already visible, + /// or past its undo window. + pub failures: Vec, +} + +/// POST /api/history/restore -- Undo trashed history deletions. +/// +/// Per-id like the bulk action: one id whose window has closed or that is +/// already visible does not stop the others from coming back. +pub async fn h_history_restore( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + if body.ids.len() > MAX_HISTORY_BULK_IDS { + return Err(ApiError::bad_request("too many ids in one bulk request")); + } + let ids = body.ids; + let (restored, failures) = qm_blocking(&state, move |qm| { + let mut restored = Vec::with_capacity(ids.len()); + let mut failures = Vec::new(); + for id in ids { + match qm.history_restore(std::slice::from_ref(&id), chrono::Utc::now())? { + ids if !ids.is_empty() => restored.extend(ids), + _ => failures.push(BulkActionFailure { + id, + error: ApiError::not_found( + "History entry is not restorable (unknown, or its undo window has closed)", + ), + }), + } + } + Ok((restored, failures)) + }) + .await?; + Ok(Json(HistoryRestoreResponse { + status: failures.is_empty(), + restored, + failures, + })) } /// DELETE /api/history -- Clear all history. @@ -1121,6 +1251,10 @@ pub struct HistoryBulkBody { pub days: Option, pub category: Option, pub search: Option, + /// With `delete`: move the entries into the undo window instead of + /// deleting them, and return their ids and deadline in the response. + #[serde(default)] + pub trash: bool, } /// POST /api/history/bulk -- Delete or retry several history entries. @@ -1133,7 +1267,7 @@ pub struct HistoryBulkBody { pub async fn h_history_bulk( State(state): State>, Json(body): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { let action = body.action.trim().to_ascii_lowercase(); if !matches!(action.as_str(), "delete" | "retry") { return Err(ApiError::bad_request("action must be `delete` or `retry`")); @@ -1178,6 +1312,8 @@ pub async fn h_history_bulk( return Err(ApiError::bad_request("a bulk action needs ids or a status")); } + let trash = body.trash && action == "delete"; + let deadline = chrono::Utc::now() + HISTORY_UNDO_WINDOW; let requested = body.ids; let filter = HistoryFilter { completed_since: body @@ -1190,7 +1326,7 @@ pub async fn h_history_bulk( .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()), }; - let (succeeded, failures) = qm_blocking(&state, move |qm| { + let (succeeded, failures, trashed) = qm_blocking(&state, move |qm| { // The explicit ids first, then every entry the status names. An id // repeated across the two is acted on once. let mut seen = std::collections::HashSet::new(); @@ -1210,12 +1346,24 @@ pub async fn h_history_bulk( let mut succeeded = 0usize; let mut failures: Vec = Vec::new(); + let mut trashed: Vec = Vec::new(); for id in targets { let error = if action == "delete" { // `history_remove` is idempotent, so look the entry up first: // a stale id in a client's selection is a failure to report. match qm.history_get(&id)? { None => Some(ApiError::not_found("History entry not found")), + Some(_) if trash => { + match qm.history_trash(std::slice::from_ref(&id), deadline)? { + moved if moved.is_empty() => { + Some(ApiError::not_found("History entry not found")) + } + moved => { + trashed.extend(moved); + None + } + } + } Some(_) => match qm.history_remove(&id) { Ok(()) => None, Err(e) => Some(ApiError::from(e)), @@ -1239,15 +1387,17 @@ pub async fn h_history_bulk( Some(error) => failures.push(BulkActionFailure { id, error }), } } - Ok((succeeded, failures)) + Ok((succeeded, failures, trashed)) }) .await?; - Ok(Json(BulkActionResponse { + let trashed = trash.then(|| trash_response(trashed, deadline)); + Ok(Json(HistoryBulkResponse { status: failures.is_empty(), succeeded, failed: failures.len(), failures, + trashed, })) } @@ -2984,6 +3134,18 @@ pub struct BulkActionFailure { pub error: ApiError, } +/// Response to `POST /api/history/bulk`. `trashed` is present only when the +/// caller asked for the undo window. +#[derive(Serialize)] +pub struct HistoryBulkResponse { + pub status: bool, + pub succeeded: usize, + pub failed: usize, + pub failures: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed: Option, +} + /// POST /api/queue/bulk -- Perform an action on multiple jobs. pub async fn h_queue_bulk_action( State(state): State>, diff --git a/apps/rustnzb/src/server.rs b/apps/rustnzb/src/server.rs index 7a6d276c..94f59303 100644 --- a/apps/rustnzb/src/server.rs +++ b/apps/rustnzb/src/server.rs @@ -279,6 +279,7 @@ fn build_api_router(state: Arc) -> Router { .route("/history/{id}/logs", get(handlers::h_history_logs)) .route("/history", delete(handlers::h_history_clear)) .route("/history/bulk", post(handlers::h_history_bulk)) + .route("/history/restore", post(handlers::h_history_restore)) .route("/statistics", get(handlers::h_global_statistics)) // Config .route("/config", get(handlers::h_config_get)) diff --git a/apps/rustnzb/tests/history_bulk_api.rs b/apps/rustnzb/tests/history_bulk_api.rs index 134a118a..c6925813 100644 --- a/apps/rustnzb/tests/history_bulk_api.rs +++ b/apps/rustnzb/tests/history_bulk_api.rs @@ -263,3 +263,181 @@ async fn delete_by_status_honours_the_window_category_and_search() { assert_eq!(response["succeeded"], 0, "{response}"); assert_eq!(remaining_ids(&app), vec!["old-fail"]); } + +/// The undo path: a trashed delete disappears from history, then a restore +/// brings the same row (and its NZB) back. A single delete's response carries +/// the id the toast needs to undo it. +#[tokio::test] +async fn single_delete_with_trash_can_be_restored() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let access = setup_auth(&client, &app.base_url).await; + seed( + &app, + &[ + entry("keep", JobStatus::Failed, true), + entry("undo", JobStatus::Completed, true), + ], + ); + + let body = client + .delete(format!("{}/api/history/undo?trash=true", app.base_url)) + .bearer_auth(&access) + .send() + .await + .unwrap(); + assert_eq!(body.status(), StatusCode::OK); + let body = body.json::().await.unwrap(); + assert_eq!(body["entries"][0]["id"], "undo", "{body}"); + assert!(body["purge_after"].as_str().is_some(), "{body}"); + assert_eq!(remaining_ids(&app), vec!["keep"]); + + let restored = client + .post(format!("{}/api/history/restore", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"ids": ["undo", "missing"]})) + .send() + .await + .unwrap(); + assert_eq!(restored.status(), StatusCode::OK); + let restored = restored.json::().await.unwrap(); + assert_eq!(restored["restored"][0], "undo", "{restored}"); + assert_eq!(restored["failures"][0]["id"], "missing", "{restored}"); + assert_eq!(restored["status"], false, "{restored}"); + + let mut left = remaining_ids(&app); + left.sort(); + assert_eq!(left, vec!["keep", "undo"]); + let nzb = app + .state + .queue_manager + .with_db(|db| db.history_get_nzb_data("undo").unwrap()); + assert!(nzb.is_some(), "undo must keep the stored NZB"); + + // The default delete is still a hard delete, and cannot be undone. + let gone = client + .delete(format!("{}/api/history/keep", app.base_url)) + .bearer_auth(&access) + .send() + .await + .unwrap(); + assert_eq!(gone.status(), StatusCode::OK); + assert_eq!(remaining_ids(&app), vec!["undo"]); + let refused = client + .post(format!("{}/api/history/restore", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"ids": ["keep"]})) + .send() + .await + .unwrap(); + let refused = refused.json::().await.unwrap(); + assert_eq!(refused["status"], false, "{refused}"); + assert_eq!(refused["failures"][0]["id"], "keep", "{refused}"); +} + +/// A bulk delete with `trash: true` returns every id and deadline, so one +/// toast can undo a whole selection, and status-wide clears work the same way. +#[tokio::test] +async fn bulk_delete_with_trash_returns_ids_that_restore() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let access = setup_auth(&client, &app.base_url).await; + seed( + &app, + &[ + entry("a", JobStatus::Completed, false), + entry("b", JobStatus::Failed, false), + entry("c", JobStatus::Completed, false), + ], + ); + + let body = client + .post(format!("{}/api/history/bulk", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"action": "delete", "ids": ["a", "b"], "trash": true})) + .send() + .await + .unwrap(); + assert_eq!(body.status(), StatusCode::OK); + let body = body.json::().await.unwrap(); + assert_eq!(body["succeeded"], 2, "{body}"); + assert!(body["trashed"]["purge_after"].as_str().is_some(), "{body}"); + let mut ids: Vec = body["trashed"]["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].as_str().unwrap().to_string()) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b"], "{body}"); + assert_eq!(remaining_ids(&app), vec!["c"]); + + let restored = client + .post(format!("{}/api/history/restore", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"ids": ids})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(restored["status"], true, "{restored}"); + let mut left = remaining_ids(&app); + left.sort(); + assert_eq!(left, vec!["a", "b", "c"]); + + // A status-wide clear is trashable too, and names what to restore. + let cleared = client + .post(format!("{}/api/history/bulk", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"action": "delete", "status": "completed", "trash": true})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!( + cleared["trashed"]["entries"].as_array().unwrap().len(), + 2, + "{cleared}" + ); + assert_eq!(remaining_ids(&app), vec!["b"]); +} + +/// One id past its undo window fails on its own; another in the same request +/// still comes back. +#[tokio::test] +async fn restore_past_the_deadline_is_a_per_id_failure() { + let app = start_test_server(Vec::new()).await; + let client = reqwest::Client::new(); + let access = setup_auth(&client, &app.base_url).await; + seed( + &app, + &[ + entry("late", JobStatus::Completed, false), + entry("live", JobStatus::Completed, false), + ], + ); + app.state.queue_manager.with_db(|db| { + db.history_trash(&["late".into()], Utc::now() - chrono::Duration::seconds(1)) + .unwrap(); + db.history_trash(&["live".into()], Utc::now() + chrono::Duration::seconds(30)) + .unwrap(); + }); + + let body = client + .post(format!("{}/api/history/restore", app.base_url)) + .bearer_auth(&access) + .json(&serde_json::json!({"ids": ["late", "live"]})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(body["restored"], serde_json::json!(["live"]), "{body}"); + assert_eq!(body["failures"][0]["id"], "late", "{body}"); + assert_eq!(remaining_ids(&app), vec!["live"]); +} diff --git a/crates/nzb-core/src/db.rs b/crates/nzb-core/src/db.rs index 35ec4c7a..c8cca0aa 100644 --- a/crates/nzb-core/src/db.rs +++ b/crates/nzb-core/src/db.rs @@ -656,6 +656,75 @@ impl Database { applied = 19; } + if version < 20 { + info!("Applying database migration v20: restorable history deletions"); + // A history delete the UI offers to undo marks the row instead of + // dropping it: `trash = 1` hides it from every history read, and + // `trash_deadline` says when it stops being restorable. The row + // keeps its NZB and retry blobs, so an undo is lossless; a purge + // drops the row and its ledger entries exactly like a hard delete. + let has_history: bool = self + .conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = 'history'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + if has_history { + add_column_if_missing(&tx, "history", "trash", "INTEGER NOT NULL DEFAULT 0")?; + add_column_if_missing(&tx, "history", "trash_deadline", "TEXT")?; + // `trash` is added after `nzb_data`, so reading it from + // `history` walks the blob's overflow chain (v18's reason for + // the narrow copy). List queries filter the copy instead. + let has_meta: bool = tx + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master + WHERE type = 'table' AND name = 'history_list_meta'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + if has_meta { + add_column_if_missing(&tx, "history_list_meta", "trash", "INTEGER")?; + tx.execute_batch( + "DROP TRIGGER IF EXISTS history_list_meta_insert; + DROP TRIGGER IF EXISTS history_list_meta_update; + DROP TRIGGER IF EXISTS history_list_meta_delete; + CREATE TRIGGER history_list_meta_insert AFTER INSERT ON history BEGIN + INSERT OR REPLACE INTO history_list_meta ( + id, download_time_secs, server_stats, failure_code, + post_processing, delete_archives, par2_volumes_fetched, + par2_volumes_available, trash) + VALUES ( + NEW.id, NEW.download_time_secs, NEW.server_stats, + NEW.failure_code, NEW.post_processing, NEW.delete_archives, + NEW.par2_volumes_fetched, NEW.par2_volumes_available, + NEW.trash); + END; + CREATE TRIGGER history_list_meta_update AFTER UPDATE ON history BEGIN + DELETE FROM history_list_meta WHERE id = OLD.id; + INSERT OR REPLACE INTO history_list_meta ( + id, download_time_secs, server_stats, failure_code, + post_processing, delete_archives, par2_volumes_fetched, + par2_volumes_available, trash) + VALUES ( + NEW.id, NEW.download_time_secs, NEW.server_stats, + NEW.failure_code, NEW.post_processing, NEW.delete_archives, + NEW.par2_volumes_fetched, NEW.par2_volumes_available, + NEW.trash); + END; + CREATE TRIGGER history_list_meta_delete AFTER DELETE ON history BEGIN + DELETE FROM history_list_meta WHERE id = OLD.id; + END; + UPDATE history_list_meta SET trash = COALESCE( + (SELECT trash FROM history h WHERE h.id = history_list_meta.id), 0);", + )?; + } + } + applied = 20; + } + if applied > version { stamp_schema_version(&tx, applied)?; tx.commit()?; @@ -1040,7 +1109,8 @@ impl Database { UNION ALL SELECT id, name, LOWER(name) = LOWER(?1), COALESCE(nzb_digest = ?2, 0), 'history' FROM history - WHERE LOWER(name) = LOWER(?1) OR (?2 IS NOT NULL AND nzb_digest = ?2)", + WHERE trash = 0 + AND (LOWER(name) = LOWER(?1) OR (?2 IS NOT NULL AND nzb_digest = ?2))", )?; let matches = stmt .query_map(params![name, digest], |row| { @@ -1179,7 +1249,8 @@ impl Database { /// List history entries, most recent first. pub fn history_list(&self, limit: usize) -> Result, NzbError> { let mut stmt = self.conn.prepare(&format!( - "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM} ORDER BY h.completed_at DESC LIMIT ?1" + "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM} + WHERE COALESCE(m.trash, 0) = 0 ORDER BY h.completed_at DESC LIMIT ?1" ))?; let entries = stmt .query_map(params![limit as i64], history_list_row)? @@ -1198,7 +1269,10 @@ impl Database { ) -> Result<(Vec, usize), NzbError> { let (where_sql, args) = filter.to_sql(); let total: i64 = self.conn.query_row( - &format!("SELECT COUNT(*) FROM history{where_sql}"), + &format!( + "SELECT COUNT(*) FROM {HISTORY_LIST_FROM}{}", + with_condition(&where_sql, "COALESCE(m.trash, 0) = 0") + ), rusqlite::params_from_iter(args.iter()), |row| row.get(0), )?; @@ -1219,9 +1293,11 @@ impl Database { /// Distinct non-empty history categories, sorted. pub fn history_categories(&self) -> Result, NzbError> { - let mut stmt = self.conn.prepare( - "SELECT DISTINCT category FROM history WHERE category <> '' ORDER BY category", - )?; + let mut stmt = self.conn.prepare(&format!( + "SELECT DISTINCT h.category FROM {HISTORY_LIST_FROM} + WHERE COALESCE(m.trash, 0) = 0 AND h.category <> '' + ORDER BY h.category" + ))?; let categories = stmt .query_map([], |row| row.get(0))? .collect::, _>>()?; @@ -1237,9 +1313,10 @@ impl Database { ) -> Result, NzbError> { let (where_sql, args) = filter.to_sql(); let mut stmt = self.conn.prepare(&format!( - "SELECT id, name, category, status, total_bytes, downloaded_bytes, added_at, - completed_at, output_dir, error_message - FROM history{where_sql} ORDER BY completed_at DESC" + "SELECT h.id, h.name, h.category, h.status, h.total_bytes, h.downloaded_bytes, + h.added_at, h.completed_at, h.output_dir, h.error_message + FROM {HISTORY_LIST_FROM}{} ORDER BY h.completed_at DESC", + with_condition(&where_sql, "COALESCE(m.trash, 0) = 0") ))?; let rows = stmt .query_map(rusqlite::params_from_iter(args.iter()), |row| { @@ -1264,7 +1341,8 @@ impl Database { /// are skipped. Like [`Database::history_list`], blobs are not loaded. pub fn history_get_listed(&self, ids: &[String]) -> Result, NzbError> { let mut stmt = self.conn.prepare(&format!( - "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM} WHERE h.id = ?1" + "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM} + WHERE h.id = ?1 AND COALESCE(m.trash, 0) = 0" ))?; let mut entries = Vec::with_capacity(ids.len()); for id in ids { @@ -1310,13 +1388,18 @@ impl Database { /// `LIMIT 0` subquery would otherwise match nothing and delete every /// row (GH #136). Callers normalize zero away already; this is the last /// line of defence. + /// + /// Trashed entries are not part of the limit: they are already gone from + /// the user's view, and retention must not silently consume an undo + /// window that the purge deadline owns. pub fn history_enforce_retention(&self, max_entries: usize) -> Result<(), NzbError> { if max_entries == 0 { return Ok(()); } self.conn.execute( - "DELETE FROM history WHERE id NOT IN ( - SELECT id FROM history ORDER BY completed_at DESC LIMIT ?1 + "DELETE FROM history WHERE trash = 0 AND id NOT IN ( + SELECT id FROM history WHERE trash = 0 + ORDER BY completed_at DESC LIMIT ?1 )", params![max_entries as i64], )?; @@ -1324,6 +1407,146 @@ impl Database { Ok(()) } + /// Move history rows into the undo window instead of deleting them. + /// + /// Each row keeps its NZB and retry blobs, so [`Self::history_restore`] is + /// lossless. Returns the summaries of the rows actually trashed, in the + /// order given; an id that is unknown or already trashed is skipped, since + /// the caller reports that as a per-id failure. + pub fn history_trash( + &self, + ids: &[String], + deadline: chrono::DateTime, + ) -> Result, NzbError> { + let mut trashed = Vec::with_capacity(ids.len()); + let tx = self.conn.unchecked_transaction()?; + for id in ids { + let changed = tx.execute( + "UPDATE history SET trash = 1, trash_deadline = ?2 + WHERE id = ?1 AND trash = 0", + params![id, deadline.to_rfc3339()], + )?; + if changed == 0 { + continue; + } + if let Some(summary) = history_summary_by_id(&tx, id)? { + trashed.push(summary); + } + } + tx.commit()?; + Ok(trashed) + } + + /// Bring trashed rows back before their deadline. Returns the ids that + /// were restored, in the order given; an unknown id, or one whose window + /// has closed, is skipped. + pub fn history_restore( + &self, + ids: &[String], + now: chrono::DateTime, + ) -> Result, NzbError> { + let mut restored = Vec::with_capacity(ids.len()); + let tx = self.conn.unchecked_transaction()?; + for id in ids { + let changed = tx.execute( + "UPDATE history SET trash = 0, trash_deadline = NULL + WHERE id = ?1 AND trash = 1 + AND (trash_deadline IS NULL OR trash_deadline >= ?2)", + params![id, now.to_rfc3339()], + )?; + if changed > 0 { + restored.push(id.clone()); + } + } + tx.commit()?; + Ok(restored) + } + + /// Delete every trashed row whose deadline has passed, and the ledger rows + /// of those jobs. Returns the summaries that were purged so the caller can + /// reclaim their retained work directories. + pub fn history_purge_expired( + &self, + now: chrono::DateTime, + ) -> Result, NzbError> { + let tx = self.conn.unchecked_transaction()?; + let mut purged = Vec::new(); + { + let mut stmt = tx.prepare( + "SELECT id, name, category, status, total_bytes, downloaded_bytes, added_at, + completed_at, output_dir, error_message + FROM history + WHERE trash = 1 AND (trash_deadline IS NULL OR trash_deadline < ?1)", + )?; + let rows = stmt.query_map(params![now.to_rfc3339()], |row| { + Ok(HistorySummary { + id: row.get(0)?, + name: row.get(1)?, + category: row.get(2)?, + status: parse_status(&row.get::<_, String>(3)?), + total_bytes: row.get::<_, i64>(4)? as u64, + downloaded_bytes: row.get::<_, i64>(5)? as u64, + added_at: parse_datetime(&row.get::<_, String>(6)?), + completed_at: parse_datetime(&row.get::<_, String>(7)?), + output_dir: row.get::<_, String>(8)?.into(), + error_message: row.get(9)?, + }) + })?; + for row in rows { + purged.push(row?); + } + } + tx.execute( + "DELETE FROM history + WHERE trash = 1 AND (trash_deadline IS NULL OR trash_deadline < ?1)", + params![now.to_rfc3339()], + )?; + tx.commit()?; + self.ledger_drop_orphans()?; + Ok(purged) + } + + /// Every trashed row, regardless of deadline. Used to keep a trashed + /// row's retained work directories out of the orphan sweep while its + /// undo window is open. + pub fn history_trashed_summaries(&self) -> Result, NzbError> { + let mut stmt = self.conn.prepare( + "SELECT id, name, category, status, total_bytes, downloaded_bytes, added_at, + completed_at, output_dir, error_message + FROM history WHERE trash = 1", + )?; + let rows = stmt + .query_map([], |row| { + Ok(HistorySummary { + id: row.get(0)?, + name: row.get(1)?, + category: row.get(2)?, + status: parse_status(&row.get::<_, String>(3)?), + total_bytes: row.get::<_, i64>(4)? as u64, + downloaded_bytes: row.get::<_, i64>(5)? as u64, + added_at: parse_datetime(&row.get::<_, String>(6)?), + completed_at: parse_datetime(&row.get::<_, String>(7)?), + output_dir: row.get::<_, String>(8)?.into(), + error_message: row.get(9)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Ids of every trashed row, regardless of deadline. Used to keep their + /// retained work directories out of the orphan sweep while an undo is + /// still possible. + pub fn history_trashed_ids(&self) -> Result, NzbError> { + let mut stmt = self + .conn + .prepare("SELECT id FROM history WHERE trash = 1")?; + let ids = stmt + .query_map([], |row| row.get(0))? + .collect::, _>>()?; + Ok(ids) + } + /// Get a single history entry by ID. pub fn history_get(&self, id: &str) -> Result, NzbError> { let mut stmt = self.conn.prepare( @@ -1331,7 +1554,8 @@ impl Database { h.added_at, h.completed_at, m.download_time_secs, h.output_dir, h.stages, h.error_message, m.server_stats, m.failure_code, m.post_processing, m.delete_archives, m.par2_volumes_fetched, m.par2_volumes_available - FROM history h LEFT JOIN history_list_meta m ON m.id = h.id WHERE h.id = ?1", + FROM history h LEFT JOIN history_list_meta m ON m.id = h.id + WHERE h.id = ?1 AND COALESCE(m.trash, 0) = 0", )?; let result = stmt.query_row(params![id], |row| { @@ -1415,9 +1639,11 @@ impl Database { /// Count history entries. pub fn history_count(&self) -> Result { - let count: i64 = self - .conn - .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))?; + let count: i64 = self.conn.query_row( + &format!("SELECT COUNT(*) FROM {HISTORY_LIST_FROM} WHERE COALESCE(m.trash, 0) = 0"), + [], + |row| row.get(0), + )?; Ok(count as usize) } @@ -1727,15 +1953,51 @@ const HISTORY_LIST_COLUMNS: &str = "h.id, h.name, h.category, h.status, h.total_ /// Source of [`HISTORY_LIST_COLUMNS`]. const HISTORY_LIST_FROM: &str = "history h LEFT JOIN history_list_meta m ON m.id = h.id"; +/// Append `condition` to a filter's ` WHERE ...` clause (or start one). +fn with_condition(where_sql: &str, condition: &str) -> String { + match where_sql.strip_prefix(" WHERE ") { + Some(rest) => format!(" WHERE {condition} AND {rest}"), + None => format!(" WHERE {condition}"), + } +} + fn history_page_sql(where_sql: &str, arg_count: usize) -> String { + // `trash = 0` is part of the page, not the caller's filter, so a paged + // request can never see a row that is inside its undo window. format!( - "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM}{where_sql} + "SELECT {HISTORY_LIST_COLUMNS} FROM {HISTORY_LIST_FROM}{} ORDER BY h.completed_at DESC LIMIT ?{} OFFSET ?{}", + with_condition(where_sql, "COALESCE(m.trash, 0) = 0"), arg_count + 1, arg_count + 2 ) } +/// One [`HistorySummary`] by id, read inside an open transaction. +fn history_summary_by_id(conn: &Connection, id: &str) -> Result, NzbError> { + let mut stmt = conn.prepare( + "SELECT id, name, category, status, total_bytes, downloaded_bytes, added_at, + completed_at, output_dir, error_message + FROM history WHERE id = ?1", + )?; + Ok(stmt + .query_row(params![id], |row| { + Ok(HistorySummary { + id: row.get(0)?, + name: row.get(1)?, + category: row.get(2)?, + status: parse_status(&row.get::<_, String>(3)?), + total_bytes: row.get::<_, i64>(4)? as u64, + downloaded_bytes: row.get::<_, i64>(5)? as u64, + added_at: parse_datetime(&row.get::<_, String>(6)?), + completed_at: parse_datetime(&row.get::<_, String>(7)?), + output_dir: row.get::<_, String>(8)?.into(), + error_message: row.get(9)?, + }) + }) + .optional()?) +} + fn history_list_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let stages_json: String = row.get::<_, Option>(10)?.unwrap_or_default(); let stages: Vec = serde_json::from_str(&stages_json).unwrap_or_default(); @@ -2639,6 +2901,161 @@ mod tests { ); } + /// A trashed row disappears from every history read, keeps its blobs for + /// the undo, and comes back unchanged when it is restored. + #[test] + fn history_trash_hides_the_row_and_restore_brings_it_back() { + let db = Database::open_memory().unwrap(); + let mut entry = make_history("undo-1", "Undo.Me"); + entry.nzb_data = Some(b"".to_vec()); + entry.retry_data = Some(b"retry".to_vec()); + db.history_insert(&entry).unwrap(); + db.history_insert(&make_history("other", "Other")).unwrap(); + + let now = Utc::now(); + let trashed = db + .history_trash( + &["undo-1".into(), "missing".into()], + now + chrono::Duration::seconds(10), + ) + .unwrap(); + assert_eq!(trashed.len(), 1, "only the existing row is reported"); + assert_eq!(trashed[0].id, "undo-1"); + assert_eq!(db.history_count().unwrap(), 1); + + assert!(db.history_get("undo-1").unwrap().is_none()); + assert!( + db.history_get_listed(&["undo-1".into()]) + .unwrap() + .is_empty() + ); + assert!( + db.history_list(10) + .unwrap() + .iter() + .all(|e| e.id != "undo-1") + ); + let (page, total) = db.history_page(&HistoryFilter::default(), 0, 50).unwrap(); + assert_eq!(total, 1); + assert!(page.iter().all(|e| e.id != "undo-1")); + assert!( + !db.history_categories().unwrap().is_empty(), + "another row still supplies the category" + ); + assert!( + db.history_summaries(&HistoryFilter::default()) + .unwrap() + .iter() + .all(|e| e.id != "undo-1") + ); + assert!( + db.find_duplicate_candidates("Undo.Me", None) + .unwrap() + .is_empty(), + "a trashed row must not block a re-add" + ); + + // The blobs survive the trash, which is what makes undo lossless. + assert_eq!( + db.history_get_nzb_data("undo-1").unwrap().as_deref(), + Some(&b""[..]) + ); + assert_eq!( + db.history_get_retry_data("undo-1").unwrap().as_deref(), + Some(&b"retry"[..]) + ); + assert_eq!(db.history_trashed_ids().unwrap(), vec!["undo-1"]); + + let restored = db + .history_restore(&["undo-1".into(), "missing".into()], now) + .unwrap(); + assert_eq!(restored, vec!["undo-1"]); + assert_eq!(db.history_count().unwrap(), 2); + let got = db.history_get("undo-1").unwrap().unwrap(); + assert_eq!(got.name, "Undo.Me"); + assert_eq!( + db.find_duplicate_candidates("Undo.Me", None).unwrap().len(), + 1 + ); + assert!(db.history_trashed_ids().unwrap().is_empty()); + } + + /// The deadline is the whole contract: a restore is accepted up to it and + /// refused after it, and only a purge removes the expired row. + #[test] + fn history_restore_is_refused_after_the_deadline_until_a_purge() { + let db = Database::open_memory().unwrap(); + db.history_insert(&make_history("expiring", "Expiring")) + .unwrap(); + let trashed_at = Utc::now(); + let deadline = trashed_at + chrono::Duration::seconds(10); + db.history_trash(&["expiring".into()], deadline).unwrap(); + + let after = deadline + chrono::Duration::seconds(1); + assert!( + db.history_restore(&["expiring".into()], after) + .unwrap() + .is_empty() + ); + assert_eq!(db.history_count().unwrap(), 0, "still hidden from reads"); + + // Not yet expired, and not purged by a sweep that runs before the + // deadline, so the row is still physically there. + assert!(db.history_purge_expired(trashed_at).unwrap().is_empty()); + assert_eq!(db.history_trashed_ids().unwrap(), vec!["expiring"]); + + let purged = db.history_purge_expired(after).unwrap(); + assert_eq!(purged.len(), 1); + assert_eq!(purged[0].id, "expiring"); + assert!(db.history_get_nzb_data("expiring").unwrap().is_none()); + assert!(db.history_trashed_ids().unwrap().is_empty()); + } + + /// Expired rows are purged even when the deadline is already in the past + /// when the sweep runs, and the job's ledger rows go with them. + #[test] + fn history_purge_removes_the_row_and_its_ledger_entries() { + let db = Database::open_memory().unwrap(); + db.history_insert(&make_history("trash-ledger", "Ledger")) + .unwrap(); + note_present(&db, "trash-ledger", 0); + db.history_trash( + &["trash-ledger".into()], + Utc::now() - chrono::Duration::seconds(1), + ) + .unwrap(); + + assert_eq!(db.history_purge_expired(Utc::now()).unwrap().len(), 1); + assert_eq!(ledger_rows(&db, "trash-ledger"), 0); + } + + /// Retention counts only visible rows: a trashed row must not consume a + /// slot, and must not be deleted by the retention pass either. + #[test] + fn history_enforce_retention_ignores_trashed_rows() { + let db = Database::open_memory().unwrap(); + for i in 0..3 { + db.history_insert(&make_history(&format!("keep-{i}"), &format!("Keep {i}"))) + .unwrap(); + } + db.history_insert(&make_history("trashed", "Trashed")) + .unwrap(); + let future = Utc::now() + chrono::Duration::seconds(10); + db.history_trash(&["trashed".into()], future).unwrap(); + + db.history_enforce_retention(3).unwrap(); + assert_eq!(db.history_count().unwrap(), 3); + assert_eq!( + db.history_trashed_ids().unwrap(), + vec!["trashed"], + "retention must leave the undo window to the purge deadline" + ); + + // Restoring after a retention pass still works while the window is open. + db.history_restore(&["trashed".into()], Utc::now()).unwrap(); + assert_eq!(db.history_count().unwrap(), 4); + } + /// Applying the schema is one transaction, not one per migration. /// /// Each migration used to commit separately, and every commit is an fsync. @@ -2658,7 +3075,7 @@ mod tests { row.get(0) }) .unwrap(); - assert!(version >= 19, "schema should reach v19, got {version}"); + assert!(version >= 20, "schema should reach v20, got {version}"); // Every migration's objects are present, which is only true if the // single batch ran to the end before stamping the version. diff --git a/crates/nzb-web/src/queue_manager.rs b/crates/nzb-web/src/queue_manager.rs index 593679a8..bb0c21ad 100644 --- a/crates/nzb-web/src/queue_manager.rs +++ b/crates/nzb-web/src/queue_manager.rs @@ -5459,6 +5459,63 @@ impl QueueManager { Ok(()) } + /// Move history entries into the undo window instead of deleting them. + /// + /// Their retained work directories stay put: the row still owns them + /// until the undo window closes, so a restore is lossless. + pub fn history_trash( + &self, + ids: &[String], + deadline: DateTime, + ) -> crate::nzb_core::Result> { + let trashed = { + let db = self.db.lock(); + db.history_trash(ids, deadline)? + }; + if !trashed.is_empty() { + self.history_changed(); + } + Ok(trashed) + } + + /// Bring trashed entries back before their deadline. + pub fn history_restore( + &self, + ids: &[String], + now: DateTime, + ) -> crate::nzb_core::Result> { + let restored = { + let db = self.db.lock(); + db.history_restore(ids, now)? + }; + if !restored.is_empty() { + self.history_changed(); + } + Ok(restored) + } + + /// Purge every trashed entry whose undo window has closed, then reclaim + /// the work directories only those rows still referenced. + pub fn history_purge_expired(&self, now: DateTime) -> crate::nzb_core::Result { + let (purged, retained) = { + let db = self.db.lock(); + let purged = db.history_purge_expired(now)?; + let retained: Vec<_> = purged + .iter() + .flat_map(|entry| self.history_work_dirs(&db, &entry.id, entry.status)) + .collect(); + (purged, retained) + }; + if purged.is_empty() { + return Ok(0); + } + self.history_changed(); + for work_dir in retained { + self.remove_unreferenced_work_dir(&work_dir, "trashed history entry expired"); + } + Ok(purged.len()) + } + /// Clear all history. pub fn history_clear(&self) -> crate::nzb_core::Result<()> { let retained = { @@ -5526,7 +5583,11 @@ impl QueueManager { { let db = self.db.lock(); paths.extend(db.queue_list()?.into_iter().map(|job| job.work_dir)); - for entry in db.history_summaries(&HistoryFilter::default())? { + let visible = db.history_summaries(&HistoryFilter::default())?; + let trashed = db.history_trashed_summaries()?; + // A trashed row still owns its directory until its undo window + // closes, so it counts as a reference exactly like a visible one. + for entry in visible.iter().chain(trashed.iter()) { paths.push(incomplete.join(&entry.id)); if entry.status != JobStatus::Failed { continue; @@ -6223,6 +6284,14 @@ impl QueueManager { } if tick_count.is_multiple_of(30) { qm.enforce_disk_guard(); + // Trashed history rows are hard-deleted once their undo + // window closes; that is also when their retained work + // directories become reclaimable. + match qm.history_purge_expired(Utc::now()) { + Ok(0) => {} + Ok(count) => info!(count, "Purged trashed history entries"), + Err(e) => warn!(error = %e, "Failed to purge trashed history entries"), + } } } }); diff --git a/crates/nzb-web/tests/harness_incomplete_cleanup.rs b/crates/nzb-web/tests/harness_incomplete_cleanup.rs index 6ebddd04..a9edd576 100644 --- a/crates/nzb-web/tests/harness_incomplete_cleanup.rs +++ b/crates/nzb-web/tests/harness_incomplete_cleanup.rs @@ -89,6 +89,58 @@ async fn clearing_history_removes_retained_work_dirs() { assert!(engine.incomplete_dir.is_dir()); } +/// An undoable delete keeps its retained partial download until the undo +/// window closes. Otherwise a startup sweep, or the purge's own reclaim, could +/// destroy the reason the user deleted instead of clearing. +#[tokio::test] +async fn trashed_history_entry_keeps_work_dir_until_the_undo_window_closes() { + let (engine, xml) = failing_engine("hist-undo").await; + let (id, work_dir) = submit_and_fail(&engine, xml).await; + let qm = &engine.queue_manager; + + qm.history_trash( + std::slice::from_ref(&id), + chrono::Utc::now() + chrono::Duration::seconds(60), + ) + .unwrap(); + assert!( + work_dir.join("present.bin").exists(), + "a trashed entry still owns its work dir" + ); + assert!( + qm.history_get(&id).unwrap().is_none(), + "but it is gone from every history read" + ); + + // The startup-style sweep must not reclaim it while the undo is possible. + engine.queue_manager.with_db(|db| { + let trashed = db.history_trashed_ids().unwrap(); + assert_eq!(trashed, vec![id.clone()]); + }); + + // Restoring brings the row back and leaves the work dir alone. + let restored = qm + .history_restore(std::slice::from_ref(&id), chrono::Utc::now()) + .unwrap(); + assert_eq!(restored, vec![id.clone()]); + assert!(qm.history_get(&id).unwrap().is_some()); + assert!(work_dir.join("present.bin").exists()); + + // Trash it again and let the window close: the purge drops the row and + // reclaims the directory it was holding. + qm.history_trash( + std::slice::from_ref(&id), + chrono::Utc::now() - chrono::Duration::seconds(1), + ) + .unwrap(); + assert_eq!(qm.history_purge_expired(chrono::Utc::now()).unwrap(), 1); + assert!( + !work_dir.exists(), + "the expired entry's work dir is reclaimed" + ); + assert!(engine.incomplete_dir.is_dir()); +} + #[tokio::test] async fn history_delete_keeps_work_dir_a_queued_retry_is_using() { let (engine, xml) = failing_engine("hist-retry").await; diff --git a/e2e/tests/downloads-mock.spec.ts b/e2e/tests/downloads-mock.spec.ts index a7acfeee..45c349df 100644 --- a/e2e/tests/downloads-mock.spec.ts +++ b/e2e/tests/downloads-mock.spec.ts @@ -18,9 +18,13 @@ test.describe("Mock-backed downloads", () => { .getByRole("button", { name: "Upload", exact: true }) .click(); - await expect(page.getByText(/added to queue/i)).toBeVisible({ - timeout: 10000, - }); + // Scope to the snackbar: the message is mirrored into the sr-only live + // region, so a page-wide text match is ambiguous. + await expect( + page.locator('.cdk-overlay-container .mdc-snackbar__label', { + hasText: /added to queue/i, + }), + ).toBeVisible({ timeout: 10000 }); const history = page.locator(".history-toggle"); await expect(history).toHaveAttribute("aria-expanded", "true"); diff --git a/e2e/tests/history.spec.ts b/e2e/tests/history.spec.ts index d22d8f66..2e2eed7f 100644 --- a/e2e/tests/history.spec.ts +++ b/e2e/tests/history.spec.ts @@ -159,6 +159,33 @@ test.describe('5. Download History', () => { // ── 5.5 Delete history entry ─────────────────────────────────────────────── + test('5.5a deleting a history entry can be undone from the toast', async ({ page }) => { + await openAllHistory(page); + + const name = 'Good.Podcast.EP100.mp3'; + const row = page.locator('tr, .row', { hasText: name }).first(); + await expect(row).toBeVisible(); + + await row.getByRole('button', { name: 'Delete' }).click(); + await expect(page.locator('tr, .row', { hasText: name }).first()).not.toBeVisible({ + timeout: 5000, + }); + + const snack = page.locator('.cdk-overlay-container .mdc-snackbar__label', { + hasText: `Deleted "${name}"`, + }); + await expect(snack).toBeVisible(); + await page.locator('.cdk-overlay-container .mdc-snackbar__action').click(); + + // The row is back, and the undo is reported as such. + await expect(page.locator('tr, .row', { hasText: name }).first()).toBeVisible({ + timeout: 5000, + }); + await expect( + page.locator('.cdk-overlay-container .mdc-snackbar__label', { hasText: 'Restored' }), + ).toBeVisible(); + }); + test('5.5 deleting a history entry removes it from the list', async ({ page }) => { await openAllHistory(page); diff --git a/e2e/tests/queue.spec.ts b/e2e/tests/queue.spec.ts index d8a2df66..1b10ae24 100644 --- a/e2e/tests/queue.spec.ts +++ b/e2e/tests/queue.spec.ts @@ -132,15 +132,19 @@ test('4.7 selecting jobs shows bulk selection count', async ({ page }) => { const rows = page.locator('.data tbody tr'); await rows.nth(0).locator('input[type="checkbox"]').check(); - // Bulk action UI or selection count must appear - const selectionCount = page.getByText(/1 selected/i).or(page.locator('.bulk-actions')); + // Bulk action UI or selection count must appear. Scope to the bulk bar: the + // count is also mirrored into the always-mounted sr-only live region, which + // makes a page-wide text match ambiguous. + const bulkCount = page.locator('.filter-bar .bulk-count'); + const selectionCount = bulkCount.or(page.locator('.bulk-actions')); await expect(selectionCount.first()).toBeVisible(); + await expect(bulkCount).toHaveText(/1 selected/i); // Check the second row too await rows.nth(1).locator('input[type="checkbox"]').check(); // Count shows 2 - await expect(page.getByText(/2 selected/i)).toBeVisible(); + await expect(bulkCount).toHaveText(/2 selected/i); }); // ── 4.1 Queue page loads with stat cards ─────────────────────────────────────