diff --git a/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx b/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx
index 77f6619f..ae2692d0 100644
--- a/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx
+++ b/apps/readest-app/src/__tests__/components/BookDetailModal.test.tsx
@@ -3,6 +3,7 @@ import { render, cleanup, fireEvent, waitFor, screen } from '@testing-library/re
import { Book } from '@/types/book';
import BookDetailModal from '@/components/metadata/BookDetailModal';
+import { DropdownProvider } from '@/context/DropdownContext';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
@@ -72,7 +73,23 @@ vi.mock('@/components/Dialog', () => ({
default: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
isOpen ?
{children}
: null,
}));
-vi.mock('@/components/Alert', () => ({ __esModule: true, default: () => null }));
+// Expose the confirm callback with the purge flag so we can assert routing
+// without driving the real toggle UI (covered by DeleteConfirmAlert.test.tsx).
+vi.mock('@/components/DeleteConfirmAlert', () => ({
+ __esModule: true,
+ default: ({
+ showPurgeToggle,
+ onConfirm,
+ }: {
+ showPurgeToggle?: boolean;
+ onConfirm: (purgeData: boolean) => void;
+ }) => (
+
+
+
+
+ ),
+}));
vi.mock('@/components/metadata/SourceSelector', () => ({ __esModule: true, default: () => null }));
vi.mock('@/components/Spinner', () => ({ __esModule: true, default: () => null }));
@@ -122,3 +139,49 @@ describe('BookDetailModal cover refresh after save', () => {
expect(screen.getByTestId('cover').getAttribute('src')).toBe('_blank');
});
});
+
+describe('BookDetailModal purge-on-delete routing', () => {
+ const renderModal = (handlers: { handleBookDelete: () => void; handleBookPurge: () => void }) =>
+ render(
+
+
+ ,
+ );
+
+ const openStandardDelete = (container: HTMLElement) => {
+ fireEvent.click(container.querySelector('button[aria-label="Delete Book Options"]')!);
+ fireEvent.click(screen.getByText('Remove from Cloud & Device'));
+ };
+
+ it('shows the purge toggle on the standard delete and routes to purge when enabled', () => {
+ const handleBookDelete = vi.fn();
+ const handleBookPurge = vi.fn();
+ const { container } = renderModal({ handleBookDelete, handleBookPurge });
+
+ openStandardDelete(container);
+ expect(screen.getByTestId('delete-confirm').getAttribute('data-purge-toggle')).toBe('true');
+
+ fireEvent.click(screen.getByText('confirm-purge-data'));
+ expect(handleBookPurge).toHaveBeenCalledTimes(1);
+ expect(handleBookDelete).not.toHaveBeenCalled();
+ });
+
+ it('routes the standard delete to a plain delete when the toggle is off', () => {
+ const handleBookDelete = vi.fn();
+ const handleBookPurge = vi.fn();
+ const { container } = renderModal({ handleBookDelete, handleBookPurge });
+
+ openStandardDelete(container);
+ fireEvent.click(screen.getByText('confirm-keep-data'));
+ expect(handleBookDelete).toHaveBeenCalledTimes(1);
+ expect(handleBookPurge).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx
index dec935ad..6603e929 100644
--- a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx
+++ b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx
@@ -166,23 +166,17 @@ describe('BookDetailView More menu (Goodreads + Share)', () => {
});
});
-describe('BookDetailView Purge Data action', () => {
- it('offers Purge Data in the delete dropdown and calls onPurge', () => {
- const onPurge = vi.fn();
- const { container, getByText } = renderView({ onPurge });
- const toggle = container.querySelector('button[aria-label="Delete Book Options"]');
- fireEvent.click(toggle!);
- const purgeButton = getByText('Purge Data').closest('button');
- expect(purgeButton).toBeTruthy();
- fireEvent.click(purgeButton!);
- expect(onPurge).toHaveBeenCalledTimes(1);
- });
-
- it('omits Purge Data when onPurge is not provided', () => {
+describe('BookDetailView delete dropdown (purge folded into the confirm alert)', () => {
+ it('no longer offers a Purge Data action and keeps the three remove options', () => {
const { container, queryByText } = renderView();
const toggle = container.querySelector('button[aria-label="Delete Book Options"]');
fireEvent.click(toggle!);
+ // Purge is now an opt-in toggle on the delete confirmation alert, not a
+ // standalone menu item.
expect(queryByText('Purge Data')).toBeNull();
+ expect(queryByText('Remove from Cloud & Device')).toBeTruthy();
+ expect(queryByText('Remove from Cloud Only')).toBeTruthy();
+ expect(queryByText('Remove from Device Only')).toBeTruthy();
});
});
diff --git a/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx
new file mode 100644
index 00000000..2a5d1ce2
--- /dev/null
+++ b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, fireEvent, screen } from '@testing-library/react';
+
+import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
+
+vi.mock('@/hooks/useTranslation', () => ({
+ useTranslation: () => (s: string) => s,
+}));
+
+// The wrapped calls useKeyDownActions, which reads these.
+vi.mock('@/context/EnvContext', () => ({
+ useEnv: () => ({ appService: null }),
+}));
+
+vi.mock('@/store/deviceStore', () => ({
+ useDeviceControlStore: () => ({
+ acquireBackKeyInterception: vi.fn(),
+ releaseBackKeyInterception: vi.fn(),
+ }),
+}));
+
+afterEach(() => cleanup());
+
+const setup = (props?: Partial>) => {
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ render(
+ ,
+ );
+ return { onConfirm, onCancel };
+};
+
+describe('DeleteConfirmAlert purge toggle', () => {
+ it('renders the purge toggle OFF by default and confirms without purging', () => {
+ const { onConfirm } = setup();
+
+ const toggle = screen.getByRole('checkbox') as HTMLInputElement;
+ expect(toggle.checked).toBe(false);
+
+ const confirm = screen.getByText('Delete').closest('button')!;
+ expect(confirm.className).toContain('btn-warning');
+
+ fireEvent.click(confirm);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onConfirm).toHaveBeenCalledWith(false);
+ });
+
+ it('escalates to a destructive purge when the toggle is turned on', () => {
+ const { onConfirm } = setup();
+
+ const toggle = screen.getByRole('checkbox') as HTMLInputElement;
+ fireEvent.click(toggle);
+ expect(toggle.checked).toBe(true);
+
+ const confirm = screen.getByText('Purge & Delete').closest('button')!;
+ expect(confirm.className).toContain('btn-error');
+
+ fireEvent.click(confirm);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onConfirm).toHaveBeenCalledWith(true);
+ });
+
+ it('omits the toggle and always confirms without purging when showPurgeToggle is false', () => {
+ const { onConfirm } = setup({ showPurgeToggle: false });
+
+ expect(screen.queryByRole('checkbox')).toBeNull();
+
+ fireEvent.click(screen.getByText('Delete').closest('button')!);
+ expect(onConfirm).toHaveBeenCalledWith(false);
+ });
+
+ it('invokes onCancel from the Cancel button', () => {
+ const { onCancel } = setup();
+ fireEvent.click(screen.getByText('Cancel').closest('button')!);
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx
index 33396bf6..76b5c30f 100644
--- a/apps/readest-app/src/app/library/components/Bookshelf.tsx
+++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx
@@ -51,7 +51,7 @@ import { MIMETYPES, EXTS } from '@/libs/document';
import { makeSafeFilename } from '@/utils/misc';
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
-import Alert from '@/components/Alert';
+import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
@@ -74,6 +74,7 @@ interface BookshelfProps {
) => Promise;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise;
+ handleBookPurge: (book: Book, syncBooks?: boolean) => Promise;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handleLibraryNavigation: (targetGroup: string) => void;
@@ -146,6 +147,7 @@ const Bookshelf: React.FC = ({
handleBookUpload,
handleBookDownload,
handleBookDelete,
+ handleBookPurge,
handleSetSelectMode,
handleShowDetailsBook,
handleLibraryNavigation,
@@ -390,8 +392,12 @@ const Bookshelf: React.FC = ({
return filteredBooks.filter((book) => wanted.has(book.hash) && !book.deletedAt);
};
- const confirmDelete = async () => {
+ const confirmDelete = async (purgeData: boolean) => {
const books = getBooksToDelete();
+ // Toggling "purge all reading data" on the confirmation routes the whole
+ // batch through the purge path, which also wipes each book's reading-data
+ // sidecars (config/nav) instead of leaving the metadata folder behind.
+ const deleteBook = purgeData ? handleBookPurge : handleBookDelete;
const concurrency = 20;
for (let i = 0; i < books.length; i += concurrency) {
@@ -400,7 +406,7 @@ const Bookshelf: React.FC = ({
break;
}
const batch = books.slice(i, i + concurrency);
- await Promise.all(batch.map((book) => handleBookDelete(book, false)));
+ await Promise.all(batch.map((book) => deleteBook(book, false)));
}
handlePushLibrary();
setSelectedBooks([]);
@@ -843,11 +849,12 @@ const Bookshelf: React.FC = ({
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
- {
abortDeletionRef.current = true;
setShowDeleteAlert(false);
diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx
index 7dd172fe..feef5c56 100644
--- a/apps/readest-app/src/app/library/page.tsx
+++ b/apps/readest-app/src/app/library/page.tsx
@@ -1453,6 +1453,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
+ handleBookPurge={handleBookDelete('purge')}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
diff --git a/apps/readest-app/src/components/Alert.tsx b/apps/readest-app/src/components/Alert.tsx
index 82bc750a..736a600f 100644
--- a/apps/readest-app/src/components/Alert.tsx
+++ b/apps/readest-app/src/components/Alert.tsx
@@ -8,7 +8,20 @@ const Alert: React.FC<{
message: string;
onCancel: () => void;
onConfirm: () => void;
-}> = ({ title, message, onCancel, onConfirm }) => {
+ // Optional content rendered between the title/message and the actions row
+ // (e.g. the delete confirmation's "purge reading data" toggle).
+ children?: React.ReactNode;
+ confirmLabel?: string;
+ confirmButtonClassName?: string;
+}> = ({
+ title,
+ message,
+ onCancel,
+ onConfirm,
+ children,
+ confirmLabel,
+ confirmButtonClassName = 'btn-warning',
+}) => {
const _ = useTranslation();
const [isProcessing, setIsProcessing] = React.useState(false);
const divRef = useKeyDownActions({ onCancel, onConfirm });
@@ -50,18 +63,19 @@ const Alert: React.FC<{
{message}
+ {children}
diff --git a/apps/readest-app/src/components/DeleteConfirmAlert.tsx b/apps/readest-app/src/components/DeleteConfirmAlert.tsx
new file mode 100644
index 00000000..641786a3
--- /dev/null
+++ b/apps/readest-app/src/components/DeleteConfirmAlert.tsx
@@ -0,0 +1,71 @@
+import clsx from 'clsx';
+import React, { useState } from 'react';
+import { useTranslation } from '@/hooks/useTranslation';
+import Alert from './Alert';
+
+/**
+ * Delete confirmation alert with an optional, opt-in "purge all reading data"
+ * toggle. Used by both the single-book delete (BookDetailModal) and the
+ * bulk/multi-select delete (Bookshelf), so the same purge-on-delete affordance
+ * covers every standard delete path (issue #4698).
+ *
+ * The toggle defaults to OFF and is ephemeral — it resets every time the alert
+ * mounts. When turned ON the confirm action escalates to a destructive purge
+ * (wipes the book's reading progress, notes, and bookmarks), signalled by the
+ * red button + relabel so the irreversible choice reads clearly even on e-ink
+ * where colour alone is not enough.
+ */
+const DeleteConfirmAlert: React.FC<{
+ title: string;
+ message: string;
+ showPurgeToggle?: boolean;
+ onCancel: () => void;
+ onConfirm: (purgeData: boolean) => void;
+}> = ({ title, message, showPurgeToggle = false, onCancel, onConfirm }) => {
+ const _ = useTranslation();
+ const [purgeData, setPurgeData] = useState(false);
+
+ return (
+ onConfirm(purgeData)}
+ >
+ {showPurgeToggle && (
+
+ )}
+
+ );
+};
+
+export default DeleteConfirmAlert;
diff --git a/apps/readest-app/src/components/metadata/BookDetailModal.tsx b/apps/readest-app/src/components/metadata/BookDetailModal.tsx
index d117bde4..53e24137 100644
--- a/apps/readest-app/src/components/metadata/BookDetailModal.tsx
+++ b/apps/readest-app/src/components/metadata/BookDetailModal.tsx
@@ -12,7 +12,7 @@ import { useMetadataEdit } from './useMetadataEdit';
import { DeleteAction } from '@/types/system';
import { eventDispatcher } from '@/utils/event';
import { isWebAppPlatform } from '@/services/environment';
-import Alert from '@/components/Alert';
+import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
import Dialog from '@/components/Dialog';
import BookDetailView from './BookDetailView';
import BookDetailEdit from './BookDetailEdit';
@@ -32,10 +32,15 @@ interface BookDetailModalProps {
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
}
+// Purge is no longer a standalone menu action — it is an opt-in toggle on the
+// standard ('both') delete confirmation, so the menu only triggers these three.
+type DeleteMenuAction = Exclude;
+
interface DeleteConfig {
title: string;
message: string;
handler?: (book: Book) => void;
+ showPurgeToggle?: boolean;
}
const BookDetailModal: React.FC = ({
@@ -54,7 +59,7 @@ const BookDetailModal: React.FC = ({
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
- const [activeDeleteAction, setActiveDeleteAction] = useState(null);
+ const [activeDeleteAction, setActiveDeleteAction] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState(null);
@@ -83,11 +88,12 @@ const BookDetailModal: React.FC = ({
resetToOriginal,
} = useMetadataEdit(bookMeta);
- const deleteConfigs: Record = {
+ const deleteConfigs: Record = {
both: {
title: _('Confirm Deletion'),
message: _('Are you sure to delete the selected book?'),
handler: handleBookDelete,
+ showPurgeToggle: !!handleBookPurge,
},
cloud: {
title: _('Confirm Deletion'),
@@ -99,13 +105,6 @@ const BookDetailModal: React.FC = ({
message: _('Are you sure to delete the local copy of the selected book?'),
handler: handleBookDeleteLocalCopy,
},
- purge: {
- title: _('Purge Book Data'),
- message: _(
- 'This permanently erases the book and all its data, including reading progress, notes, and bookmarks. This cannot be undone.',
- ),
- handler: handleBookPurge,
- },
};
useEffect(() => {
@@ -157,17 +156,22 @@ const BookDetailModal: React.FC = ({
}
};
- const handleDeleteAction = (action: DeleteAction) => {
+ const handleDeleteAction = (action: DeleteMenuAction) => {
setActiveDeleteAction(action);
};
- const confirmDeleteAction = async () => {
+ const confirmDeleteAction = async (purgeData: boolean) => {
if (!activeDeleteAction) return;
const config = deleteConfigs[activeDeleteAction];
handleClose();
- if (config.handler) {
+ // The standard "Cloud & Device" delete escalates to a full purge when the
+ // user opts in via the confirmation toggle. The cloud-only / device-only
+ // variants keep the library entry, so purging reading data does not apply.
+ if (activeDeleteAction === 'both' && purgeData && handleBookPurge) {
+ handleBookPurge(book);
+ } else if (config.handler) {
config.handler(book);
}
};
@@ -179,7 +183,6 @@ const BookDetailModal: React.FC = ({
const handleDelete = () => handleDeleteAction('both');
const handleDeleteCloudBackup = () => handleDeleteAction('cloud');
const handleDeleteLocalCopy = () => handleDeleteAction('local');
- const handlePurge = () => handleDeleteAction('purge');
const handleShare = () => {
// Close this modal first, then hand off to the share dialog hosted by
@@ -267,7 +270,6 @@ const BookDetailModal: React.FC = ({
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
}
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
- onPurge={handleBookPurge ? handlePurge : undefined}
onDownload={handleBookDownload ? handleRedownload : undefined}
onUpload={handleBookUpload ? handleReupload : undefined}
onShare={handleShare}
@@ -300,9 +302,10 @@ const BookDetailModal: React.FC = ({
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
-
diff --git a/apps/readest-app/src/components/metadata/BookDetailView.tsx b/apps/readest-app/src/components/metadata/BookDetailView.tsx
index 96bdff15..dd5e2c4b 100644
--- a/apps/readest-app/src/components/metadata/BookDetailView.tsx
+++ b/apps/readest-app/src/components/metadata/BookDetailView.tsx
@@ -39,7 +39,6 @@ interface BookDetailViewProps {
onDelete?: () => void;
onDeleteCloudBackup?: () => void;
onDeleteLocalCopy?: () => void;
- onPurge?: () => void;
onDownload?: () => void;
onUpload?: () => void;
onShare?: () => void;
@@ -55,7 +54,6 @@ const BookDetailView: React.FC = ({
onDelete,
onDeleteCloudBackup,
onDeleteLocalCopy,
- onPurge,
onDownload,
onUpload,
onShare,
@@ -153,16 +151,6 @@ const BookDetailView: React.FC = ({
onClick={onDeleteLocalCopy}
disabled={!book.downloadedAt}
/>
- {onPurge && (
-
- )}
)}