From 94c35f999b259cbccab8afd17e91c8059e6e9aa3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 8 Jul 2026 23:00:44 +0800 Subject: [PATCH] Add bilingual EPUB filter --- .../components/BilingualFilterAlert.tsx | 115 +++++ .../src/app/library/components/Bookshelf.tsx | 108 ++++ .../library/components/SelectModeActions.tsx | 24 +- .../src/services/bilingualEpubFilter.ts | 464 ++++++++++++++++++ 4 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 apps/readest-app/src/app/library/components/BilingualFilterAlert.tsx create mode 100644 apps/readest-app/src/services/bilingualEpubFilter.ts diff --git a/apps/readest-app/src/app/library/components/BilingualFilterAlert.tsx b/apps/readest-app/src/app/library/components/BilingualFilterAlert.tsx new file mode 100644 index 00000000..faec5b0f --- /dev/null +++ b/apps/readest-app/src/app/library/components/BilingualFilterAlert.tsx @@ -0,0 +1,115 @@ +import clsx from 'clsx'; +import * as React from 'react'; +import { useState } from 'react'; +import { LuLanguages } from 'react-icons/lu'; +import { PiX } from 'react-icons/pi'; +import { useKeyDownActions } from '@/hooks/useKeyDownActions'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { BilingualFilterMode, BilingualFilterOptions } from '@/services/bilingualEpubFilter'; + +interface BilingualFilterAlertProps { + bookTitle: string; + safeAreaBottom: number; + processing: boolean; + onCancel: () => void; + onFilter: (options: BilingualFilterOptions) => void; +} + +const BilingualFilterAlert: React.FC = ({ + bookTitle, + safeAreaBottom, + processing, + onCancel, + onFilter, +}) => { + const _ = useTranslation(); + const divRef = useKeyDownActions({ onCancel }); + const [mode, setMode] = useState('auto'); + const [removeUnknown, setRemoveUnknown] = useState(false); + + const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => { + if (processing) return; + onFilter({ removeLanguage, mode, removeUnknown }); + }; + + return ( +
+
+
+ +
+ {_('Bilingual EPUB')}: {bookTitle} +
+ +
+ +
+ + +
+ +
+ + + +
+
+
+ ); +}; + +export default BilingualFilterAlert; diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 7ab7b01c..f93d617c 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -63,6 +63,11 @@ import GroupingModal from './GroupingModal'; import SetStatusAlert from './SetStatusAlert'; import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf'; import { useOpenBook } from '../hooks/useOpenBook'; +import BilingualFilterAlert from './BilingualFilterAlert'; +import { + filterBilingualEpubFile, + type BilingualFilterOptions, +} from '@/services/bilingualEpubFilter'; interface BookshelfProps { libraryBooks: Book[]; @@ -199,6 +204,8 @@ const Bookshelf: React.FC = ({ const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [showStatusAlert, setShowStatusAlert] = useState(false); const [showGroupingModal, setShowGroupingModal] = useState(false); + const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false); + const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false); const [importBookUrl] = useState(searchParams?.get('url') || ''); const abortDeletionRef = useRef(false); @@ -452,6 +459,91 @@ const Bookshelf: React.FC = ({ setShowStatusAlert(true); }; + const getSingleSelectedBook = () => { + const ids = getSelectedBooks(); + if (ids.length !== 1) return; + return filteredBooks.find((book) => book.hash === ids[0]); + }; + + const showBilingualFilterSelection = () => { + const book = getSingleSelectedBook(); + if (!book || book.format !== 'EPUB') return; + setShowSelectModeActions(false); + setShowBilingualFilterAlert(true); + }; + + const closeBilingualFilterSelection = () => { + if (bilingualFilterProcessing) return; + setShowBilingualFilterAlert(false); + setShowSelectModeActions(true); + }; + + const runBilingualFilter = async (options: BilingualFilterOptions) => { + const book = getSingleSelectedBook(); + if (!book || !appService) return; + if (book.format !== 'EPUB') { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Only EPUB books can be filtered'), + timeout: 2500, + }); + return; + } + + setBilingualFilterProcessing(true); + setLoading(true); + + try { + if (!(await appService.isBookAvailable(book))) { + if (!book.uploadedAt || !(await handleBookDownload(book, { queued: false }))) { + throw new Error('Book file is not available locally'); + } + } + + const { file } = await appService.loadBookContent(book); + try { + const result = await filterBilingualEpubFile(file, options); + const importedBook = await appService.importBook(result.file, libraryBooks); + if (!importedBook) throw new Error('Failed to import generated EPUB'); + + importedBook.group = book.group; + importedBook.groupId = book.groupId; + importedBook.groupName = book.groupName; + importedBook.tags = book.tags; + importedBook.updatedAt = Date.now(); + + await updateBooks(envConfig, [importedBook]); + handlePushLibrary(); + setSelectedBooks([]); + setShowBilingualFilterAlert(false); + handleSetSelectMode(false); + eventDispatcher.dispatch('toast', { + type: 'success', + message: _('Created {{title}}. Removed {{count}} paragraph(s).', { + title: importedBook.title || result.title, + count: result.stats.removed, + }), + timeout: 3500, + }); + } finally { + const closable = file as File & { close?: () => Promise | void }; + await closable.close?.(); + } + } catch (error) { + console.error('Failed to filter bilingual EPUB:', error); + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Failed to filter bilingual EPUB'), + timeout: 3000, + }); + setShowBilingualFilterAlert(false); + setShowSelectModeActions(true); + } finally { + setLoading(false); + setBilingualFilterProcessing(false); + } + }; + const sendSelectedBook = async () => { // "Send" hands the actual book file (epub/pdf/...) to the OS share // sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on @@ -678,6 +770,11 @@ const Bookshelf: React.FC = ({ ); const selectedBooks = getSelectedBooks(); + const selectedBilingualBook = + selectedBooks.length === 1 + ? filteredBooks.find((book) => book.hash === selectedBooks[0]) + : null; + const bilingualFilterEnabled = !!selectedBilingualBook && selectedBilingualBook.format === 'EPUB'; const isGridMode = viewMode === 'grid'; const hasItems = sortedBookshelfItems.length > 0; // In grid mode the Import-Books "+" tile is rendered as an extra grid cell @@ -889,9 +986,20 @@ const Bookshelf: React.FC = ({ onGroup={groupSelectedBooks} onDetails={openBookDetails} onStatus={showStatusSelection} + onBilingualFilter={showBilingualFilterSelection} onSend={sendSelectedBook} onDelete={deleteSelectedBooks} onCancel={() => handleSetSelectMode(false)} + bilingualFilterEnabled={bilingualFilterEnabled} + /> + )} + {showBilingualFilterAlert && selectedBilingualBook && ( + )} {showGroupingModal && selectedBooks.length > 0 && ( diff --git a/apps/readest-app/src/app/library/components/SelectModeActions.tsx b/apps/readest-app/src/app/library/components/SelectModeActions.tsx index bc0a4e0d..417f5eea 100644 --- a/apps/readest-app/src/app/library/components/SelectModeActions.tsx +++ b/apps/readest-app/src/app/library/components/SelectModeActions.tsx @@ -7,7 +7,7 @@ import { MdCheckCircleOutline, } from 'react-icons/md'; import { IoShareSocialOutline } from 'react-icons/io5'; -import { LuFolderPlus } from 'react-icons/lu'; +import { LuFolderPlus, LuLanguages } from 'react-icons/lu'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { useTranslation } from '@/hooks/useTranslation'; import { isMd5 } from '@/utils/md5'; @@ -25,6 +25,7 @@ interface SelectModeActionsProps { onGroup: () => void; onDetails: () => void; onStatus: () => void; + onBilingualFilter: () => void; // The macOS / iPad share popover is anchored to the selected book's // cover (located via its data-book-hash attribute), not to this // button — the user's visual focus is on the cover they just tapped. @@ -32,6 +33,7 @@ interface SelectModeActionsProps { onSend: () => void; onDelete: () => void; onCancel: () => void; + bilingualFilterEnabled?: boolean; } const SelectModeActions: React.FC = ({ @@ -42,9 +44,11 @@ const SelectModeActions: React.FC = ({ onGroup, onDetails, onStatus, + onBilingualFilter, onSend, onDelete, onCancel, + bilingualFilterEnabled = false, }) => { const _ = useTranslation(); @@ -110,13 +114,21 @@ const SelectModeActions: React.FC = ({
{_('Details')}
+ {sendEnabled && (