feat(library): add Purge Data and fold detail actions into a More menu (#4615) (#4626)

Resolves #4615. Re-importing updated serials left the app-generated
Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover)
on disk after a normal delete, forcing a manual cleanup. "Purge Data" now
does a Cloud & Device delete AND wipes the whole directory in one action.

The book detail action row is redesigned to Edit · Download · Upload ·
Delete · More (hamburger):
- Goodreads, Share, and Export move into the hamburger "More" menu.
- Share is enabled only when signed in and the local file exists; Export
  is enabled when the local file exists (kept on every platform since the
  bottom-bar Send is mobile/macOS-only).
- Purge Data is the red entry in the Delete menu, behind a strong confirm.

Implementation:
- DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the
  in-place source file and removeDir's the whole Books/<hash>/ folder,
  clearing downloadedAt and leaving the tombstone + queued cloud delete to
  the page (mirrors 'both'/'local').
- The library page wires handleBookDelete('purge'); BookDetailModal adds
  the purge confirm config + share/export handlers and gates Share on auth.

Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests.
i18n: 9 new keys translated across all 33 locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-17 12:28:21 +08:00
committed by GitHub
parent 5e217544f2
commit d5c02e6253
41 changed files with 630 additions and 76 deletions
@@ -5,6 +5,7 @@ import { Book } from '@/types/book';
import { getBookWithUpdatedMetadata } from '@/utils/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
@@ -27,6 +28,7 @@ interface BookDetailModalProps {
handleBookDelete?: (book: Book) => void;
handleBookDeleteCloudBackup?: (book: Book) => void;
handleBookDeleteLocalCopy?: (book: Book) => void;
handleBookPurge?: (book: Book) => void;
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
}
@@ -45,10 +47,12 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookDelete,
handleBookDeleteCloudBackup,
handleBookDeleteLocalCopy,
handleBookPurge,
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -95,6 +99,13 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
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(() => {
@@ -168,19 +179,14 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
const handleDelete = () => handleDeleteAction('both');
const handleDeleteCloudBackup = () => handleDeleteAction('cloud');
const handleDeleteLocalCopy = () => handleDeleteAction('local');
const handlePurge = () => handleDeleteAction('purge');
const handleRedownload = async () => {
const handleShare = () => {
// Close this modal first, then hand off to the share dialog hosted by
// Bookshelf (it owns the login gate + ShareBookDialog). Mirrors how the
// bookshelf context menu dispatches the same event.
handleClose();
if (handleBookDownload) {
handleBookDownload(book, { redownload: true, queued: false });
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
eventDispatcher.dispatch('show-share-dialog', { book });
};
const handleBookExport = async () => {
@@ -197,6 +203,25 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
}, 0);
};
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book, { redownload: true, queued: false });
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
};
// Sharing uploads the book to the Readest backend and mints a public link, so
// it needs a signed-in user and a resolvable on-disk file. `fileSize` is only
// non-null when getBookFileSize could actually open the local file.
const shareEnabled = !!user && fileSize !== null;
const currentDeleteConfig = activeDeleteAction ? deleteConfigs[activeDeleteAction] : null;
return (
@@ -235,14 +260,17 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
book={displayBook}
metadata={bookMeta}
fileSize={fileSize}
shareEnabled={shareEnabled}
onEdit={handleBookMetadataUpdate ? handleEditMetadata : undefined}
onDelete={handleBookDelete ? handleDelete : undefined}
onDeleteCloudBackup={
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
}
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
onPurge={handleBookPurge ? handlePurge : undefined}
onDownload={handleBookDownload ? handleRedownload : undefined}
onUpload={handleBookUpload ? handleReupload : undefined}
onShare={handleShare}
onExport={handleBookExport}
/>
)}
@@ -5,11 +5,10 @@ import {
MdOutlineCloudUpload,
MdOutlineDelete,
MdOutlineEdit,
MdSaveAlt,
MdMenu,
MdExpandMore,
MdExpandLess,
} from 'react-icons/md';
import { FaGoodreads } from 'react-icons/fa';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
@@ -35,12 +34,15 @@ interface BookDetailViewProps {
book: Book;
metadata: BookMetadata | null;
fileSize: number | null;
shareEnabled?: boolean;
onEdit?: () => void;
onDelete?: () => void;
onDeleteCloudBackup?: () => void;
onDeleteLocalCopy?: () => void;
onPurge?: () => void;
onDownload?: () => void;
onUpload?: () => void;
onShare?: () => void;
onExport?: () => void;
}
@@ -48,18 +50,25 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
book,
metadata,
fileSize,
shareEnabled,
onEdit,
onDelete,
onDeleteCloudBackup,
onDeleteLocalCopy,
onPurge,
onDownload,
onUpload,
onShare,
onExport,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
// Export and Share both read the book file off disk; `fileSize` is only
// non-null when getBookFileSize could actually open the local copy.
const hasLocalFile = fileSize !== null;
const toggleSeriesCollapse = () => {
saveSysSettings(envConfig, 'metadataSeriesCollapsed', !settings.metadataSeriesCollapsed);
};
@@ -101,12 +110,16 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<MdOutlineEdit className='hover:fill-blue-500' />
</button>
)}
<button
onClick={() => openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)))}
title={_('Search on Goodreads')}
>
<FaGoodreads className='fill-base-content' />
</button>
{book.uploadedAt && onDownload && (
<button onClick={onDownload} title={_('Download from Cloud')}>
<MdOutlineCloudDownload className='fill-base-content' />
</button>
)}
{book.downloadedAt && onUpload && (
<button onClick={onUpload} title={_('Upload to Cloud')}>
<MdOutlineCloudUpload className='fill-base-content' />
</button>
)}
{onDelete && (
<Dropdown
label={_('Delete Book Options')}
@@ -140,24 +153,65 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
onClick={onDeleteLocalCopy}
disabled={!book.downloadedAt}
/>
{onPurge && (
<MenuItem
noIcon
transient
label={_('Purge Data')}
labelClass='text-red-500'
tooltip={_('Erase the book and all its data, including reading progress')}
onClick={onPurge}
/>
)}
</div>
</Dropdown>
)}
{book.uploadedAt && onDownload && (
<button onClick={onDownload} title={_('Download from Cloud')}>
<MdOutlineCloudDownload className='fill-base-content' />
</button>
)}
{book.downloadedAt && onUpload && (
<button onClick={onUpload} title={_('Upload to Cloud')}>
<MdOutlineCloudUpload className='fill-base-content' />
</button>
)}
{book.downloadedAt && onExport && (
<button onClick={onExport} title={_('Export Book')}>
<MdSaveAlt className='fill-base-content' />
</button>
)}
<Dropdown
label={_('More Actions')}
className='dropdown-bottom dropdown-center flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdMenu className='fill-base-content' />}
>
<div
className={clsx(
'more-menu dropdown-content no-triangle !relative',
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
)}
>
<MenuItem
noIcon
transient
label={_('Search on Goodreads')}
onClick={() =>
openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)))
}
/>
{onShare && (
<MenuItem
noIcon
transient
label={_('Share Book')}
disabled={!shareEnabled}
tooltip={
shareEnabled
? undefined
: _('Sign in and make the book available to share it')
}
onClick={onShare}
/>
)}
{onExport && (
<MenuItem
noIcon
transient
label={_('Export Book')}
disabled={!hasLocalFile}
tooltip={hasLocalFile ? undefined : _('Download the book to export it')}
onClick={onExport}
/>
)}
</div>
</Dropdown>
</div>
</div>
</div>