diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index 7b27fa39..ee777810 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -69,6 +69,11 @@ "Imported {{count}} annotations_one": "Imported {{count}} annotation", "Imported {{count}} annotations_other": "Imported {{count}} annotations", "Recently read": "Recently read", + "All Books": "All Books", + "Categories": "Categories", + "Category": "Category", + "Publication Year": "Publication Year", + "Bilingual": "Bilingual", "Show recently read": "Show recently read", "Your books will appear here": "Your books will appear here", "{{count}} results_one": "{{count}} result", diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 870adbab..a4e95772 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -9,7 +9,12 @@ "Apply": "应用", "Auto Mode": "自动主题", "Behavior": "行为", + "Bilingual": "双语拆分", "Book": "书籍", + "All Books": "全部书籍", + "Categories": "分类", + "Category": "类别", + "Publication Year": "出版年份", "Bookmark": "书签", "Cancel": "取消", "Chapter": "章节", diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 74c2ee54..90f9365e 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -9,7 +9,12 @@ "Apply": "應用", "Auto Mode": "自動主題", "Behavior": "行為", + "Bilingual": "雙語拆分", "Book": "書籍", + "All Books": "全部書籍", + "Categories": "分類", + "Category": "類別", + "Publication Year": "出版年份", "Bookmark": "書籤", "Cancel": "取消", "Chapter": "章節", diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index 4fce12dc..676d4314 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -129,8 +129,16 @@ const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringi // fallback HTML and crash with `Unexpected token '<'`. All runtime-config // consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri. const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web'; +const shouldUseViewTransitions = + process.env['NODE_ENV'] !== 'development' || process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web'; export default function RootLayout({ children }: { children: React.ReactNode }) { + const app = ( + + {children} + + ); + return ( ) : null} - - - - {children} - - - + {shouldUseViewTransitions ? {app} : app} ); } 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..c2f35e14 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -50,6 +50,7 @@ import { eventDispatcher } from '@/utils/event'; import { getLocalBookFilename } from '@/utils/book'; import { MIMETYPES, EXTS } from '@/libs/document'; import { makeSafeFilename } from '@/utils/misc'; +import { getBookCategoryValues, type LibraryCategoryFilter } from '../utils/categoryFilters'; import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; @@ -63,6 +64,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[]; @@ -83,6 +89,7 @@ interface BookshelfProps { handleLibraryNavigation: (targetGroup: string) => void; handlePushLibrary: () => Promise; booksTransferProgress: { [key: string]: number | null }; + categoryFilter?: LibraryCategoryFilter; } /** @@ -170,6 +177,7 @@ const Bookshelf: React.FC = ({ handleLibraryNavigation, handlePushLibrary, booksTransferProgress, + categoryFilter = 'all', }) => { const _ = useTranslation(); const router = useRouter(); @@ -180,6 +188,7 @@ const Bookshelf: React.FC = ({ const groupId = searchParams?.get('group') || ''; const queryTerm = searchParams?.get('q') || null; + const categoryValue = searchParams?.get('categoryValue') || ''; const viewMode = searchParams?.get('view') || settings.libraryViewMode; const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy); const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'); @@ -199,6 +208,9 @@ 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 [bilingualFilterBook, setBilingualFilterBook] = useState(null); + const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false); const [importBookUrl] = useState(searchParams?.get('url') || ''); const abortDeletionRef = useRef(false); @@ -241,10 +253,20 @@ const Bookshelf: React.FC = ({ [router, searchParams], ); + const categoryFilteredBooks = useMemo(() => { + if (categoryFilter === 'all' || !categoryValue) return libraryBooks; + return libraryBooks.filter( + (book) => + !book.deletedAt && getBookCategoryValues(book, categoryFilter).includes(categoryValue), + ); + }, [libraryBooks, categoryFilter, categoryValue]); + const filteredBooks = useMemo(() => { const bookFilter = createBookFilter(queryTerm); - return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks; - }, [libraryBooks, queryTerm]); + return queryTerm + ? categoryFilteredBooks.filter((book) => bookFilter(book)) + : categoryFilteredBooks; + }, [categoryFilteredBooks, queryTerm]); const currentBookshelfItems = useMemo(() => { if (groupBy === LibraryGroupByType.Group) { @@ -452,6 +474,112 @@ 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; + setBilingualFilterBook(null); + setShowSelectModeActions(false); + setShowBilingualFilterAlert(true); + }; + + const showBilingualFilterBook = useCallback( + (book: Book) => { + if (book.format !== 'EPUB') { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Only EPUB books can be filtered'), + timeout: 2500, + }); + return; + } + setBilingualFilterBook(book); + setShowSelectModeActions(false); + setShowBilingualFilterAlert(true); + }, + [_], + ); + + const closeBilingualFilterSelection = () => { + if (bilingualFilterProcessing) return; + setShowBilingualFilterAlert(false); + setBilingualFilterBook(null); + if (isSelectMode) setShowSelectModeActions(true); + }; + + const runBilingualFilter = async (options: BilingualFilterOptions) => { + const book = bilingualFilterBook ?? 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([]); + setBilingualFilterBook(null); + 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, + }); + setBilingualFilterBook(null); + setShowBilingualFilterAlert(false); + if (isSelectMode) 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 +806,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 @@ -791,6 +924,7 @@ const Bookshelf: React.FC = ({ handleBookDelete={handleBookDelete} handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} + handleBilingualFilterBook={showBilingualFilterBook} handleLibraryNavigation={handleLibraryNavigation} handleUpdateReadingStatus={handleUpdateReadingStatus} transferProgress={ @@ -816,6 +950,7 @@ const Bookshelf: React.FC = ({ handleBookDelete, handleSetSelectMode, handleShowDetailsBook, + showBilingualFilterBook, handleLibraryNavigation, handleUpdateReadingStatus, ], @@ -889,9 +1024,20 @@ const Bookshelf: React.FC = ({ onGroup={groupSelectedBooks} onDetails={openBookDetails} onStatus={showStatusSelection} + onBilingualFilter={showBilingualFilterSelection} onSend={sendSelectedBook} onDelete={deleteSelectedBooks} onCancel={() => handleSetSelectMode(false)} + bilingualFilterEnabled={bilingualFilterEnabled} + /> + )} + {showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && ( + )} {showGroupingModal && selectedBooks.length > 0 && ( diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 8b6ecaf6..4f2f652b 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,17 +1,20 @@ import clsx from 'clsx'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useAppRouter } from '@/hooks/useAppRouter'; import { useLongPress } from '@/hooks/useLongPress'; -import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu'; +import { Menu as TauriMenu } from '@tauri-apps/api/menu'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { eventDispatcher } from '@/utils/event'; import { openExternalUrl } from '@/utils/open'; import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; +import { Overlay } from '@/components/Overlay'; +import Menu from '@/components/Menu'; +import MenuItem from '@/components/MenuItem'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; @@ -26,6 +29,17 @@ import BookItem from './BookItem'; import GroupItem from './GroupItem'; import { useOpenBook } from '../hooks/useOpenBook'; +type WebContextMenuItem = { + text: string; + action: () => void | Promise; +}; + +type WebContextMenuState = { + x: number; + y: number; + items: WebContextMenuItem[]; +} | null; + export const generateBookshelfItems = ( books: Book[], parentGroupName: string, @@ -105,6 +119,7 @@ interface BookshelfItemProps { handleBookDelete: (book: Book, syncBooks?: boolean) => Promise; handleSetSelectMode: (selectMode: boolean) => void; handleShowDetailsBook: (book: Book) => void; + handleBilingualFilterBook: (book: Book) => void; handleLibraryNavigation: (targetGroup: string) => void; handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void; } @@ -123,6 +138,7 @@ const BookshelfItem: React.FC = ({ handleBookDownload, handleSetSelectMode, handleShowDetailsBook, + handleBilingualFilterBook, handleLibraryNavigation, handleUpdateReadingStatus, }) => { @@ -131,6 +147,7 @@ const BookshelfItem: React.FC = ({ const { appService } = useEnv(); const { settings } = useSettingsStore(); const { openBook, makeBookAvailable } = useOpenBook({ setLoading, handleBookDownload }); + const [webContextMenu, setWebContextMenu] = useState(null); const showBookDetailsModal = useCallback(async (book: Book) => { handleShowDetailsBook(book); @@ -179,8 +196,7 @@ const BookshelfItem: React.FC = ({ [isSelectMode, handleLibraryNavigation], ); - const bookContextMenuHandler = async (book: Book) => { - if (!appService?.hasContextMenu) return; + const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => { const osPlatform = getOSPlatform(); const fileRevealLabel = FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default; @@ -188,7 +204,7 @@ const BookshelfItem: React.FC = ({ // in a single Menu.new({ items }) call. Appending items one-by-one with // un-awaited Menu.append() promises races on the Tauri IPC boundary and // shuffles the order on every open (issue #4389). - const itemOptions: Record = { + const itemOptions: Record = { select: { text: itemSelected ? _('Deselect Book') : _('Select Book'), action: async () => { @@ -248,6 +264,12 @@ const BookshelfItem: React.FC = ({ await openBookInReviewEditor(book, 'translate'); }, }, + bilingual: { + text: _('Bilingual'), + action: async () => { + handleBilingualFilterBook(book); + }, + }, showInFinder: { text: _(fileRevealLabel), action: async () => { @@ -288,16 +310,22 @@ const BookshelfItem: React.FC = ({ }, }, }; - const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]); - const menu = await Menu.new({ items }); - await menu.popup(); + const itemIds = getBookContextMenuItemIds(book).filter( + (id) => appService?.hasContextMenu || id !== 'showInFinder', + ); + const items = itemIds.map((id) => itemOptions[id]); + if (appService?.hasContextMenu) { + const menu = await TauriMenu.new({ items }); + await menu.popup(); + return; + } + if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items }); }; - const groupContextMenuHandler = async (group: BooksGroup) => { - if (!appService?.hasContextMenu) return; + const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => { // Single Menu.new({ items }) call keeps the order deterministic — see the // note in bookContextMenuHandler about the Menu.append() IPC race (#4389). - const items: MenuItemOptions[] = [ + const items: WebContextMenuItem[] = [ { text: itemSelected ? _('Deselect Group') : _('Select Group'), action: async () => { @@ -327,8 +355,12 @@ const BookshelfItem: React.FC = ({ }, }, ]; - const menu = await Menu.new({ items }); - await menu.popup(); + if (appService?.hasContextMenu) { + const menu = await TauriMenu.new({ items }); + await menu.popup(); + return; + } + if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items }); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -364,14 +396,28 @@ const BookshelfItem: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps const handleContextMenu = useCallback( - throttle(() => { + throttle((event?: React.MouseEvent) => { if ('format' in item) { - bookContextMenuHandler(item as Book); + bookContextMenuHandler(item as Book, event); } else { - groupContextMenuHandler(item as BooksGroup); + groupContextMenuHandler(item as BooksGroup, event); } }, 100), - [itemSelected, settings.localBooksDir], + [ + appService?.hasContextMenu, + appService?.isMobileApp, + handleBookDownload, + handleBookUpload, + handleBilingualFilterBook, + handleGroupBooks, + handleSetSelectMode, + handleUpdateReadingStatus, + item, + itemSelected, + settings.localBooksDir, + showBookDetailsModal, + toggleSelection, + ], ); const { pressing, handlers } = useLongPress( @@ -382,9 +428,11 @@ const BookshelfItem: React.FC = ({ onTap: () => { handleOpenItem(); }, - onContextMenu: () => { + onContextMenu: (event) => { if (appService?.hasContextMenu) { handleContextMenu(); + } else if (!appService?.isMobileApp) { + handleContextMenu(event); } else if (appService?.isAndroidApp) { handleSelectItem(); } @@ -414,6 +462,29 @@ const BookshelfItem: React.FC = ({ return (
+ {webContextMenu && ( + <> + setWebContextMenu(null)} className='z-40' /> + setWebContextMenu(null)} + > + {webContextMenu.items.map((menuItem) => ( + { + setWebContextMenu(null); + void menuItem.action(); + }} + /> + ))} + + + )}
void; + onImportBooksFromFiles: () => void; + onImportBooksFromDirectory?: () => void; + onImportBookFromUrl?: () => void; + onOpenCatalogManager: () => void; + onToggleSelectMode: () => void; +} + +type CategoryItem = { + id: LibraryCategoryFilter; + label: string; + count: number; + Icon: IconType; + values: CategoryValue[]; +}; + +const LibrarySidebar: React.FC = ({ + books, + isSelectMode, + onPullLibrary, + onImportBooksFromFiles, + onImportBooksFromDirectory, + onImportBookFromUrl, + onOpenCatalogManager, + onToggleSelectMode, +}) => { + const _ = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const iconSize18 = useResponsiveSize(18); + const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? ''); + const activeCategory = ensureLibraryCategoryFilter(searchParams?.get('category')); + const activeCategoryValue = searchParams?.get('categoryValue') || ''; + const [expandedCategories, setExpandedCategories] = useState>( + () => new Set(activeCategory !== 'all' ? [activeCategory] : ['author']), + ); + + const activeBooks = useMemo(() => books.filter((book) => !book.deletedAt), [books]); + const categories: CategoryItem[] = useMemo(() => { + const makeCategory = ( + id: LibraryCategoryFilter, + label: string, + Icon: IconType, + ): CategoryItem => { + const values = countCategoryValues(activeBooks, id); + return { id, label, count: values.length, Icon, values }; + }; + + return [ + { + id: 'all', + label: _('All Books'), + count: activeBooks.length, + Icon: PiBooks, + values: [], + }, + makeCategory('author', _('Authors'), PiUser), + makeCategory('publisher', _('Publisher'), MdBusiness), + makeCategory('year', _('Publication Year'), PiCalendarBlank), + makeCategory('language', _('Language'), PiGlobeHemisphereEast), + makeCategory('format', _('Category'), PiStackSimple), + makeCategory('subject', _('Subject'), PiHouseLine), + makeCategory('tag', _('Tags'), PiTag), + ]; + }, [_, activeBooks]); + + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams?.toString()); + for (const [key, value] of Object.entries(updates)) { + if (!value) { + params.delete(key); + } else { + params.set(key, value); + } + } + if (params.get('category') === 'all') params.delete('category'); + navigateToLibrary(router, params.toString()); + }, + [router, searchParams], + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedUpdateQueryParam = useCallback( + debounce((value: string) => updateParams({ q: value || null }), 500), + [updateParams], + ); + + const handleSearchChange = (event: React.ChangeEvent) => { + const newQuery = event.target.value; + setSearchQuery(newQuery); + debouncedUpdateQueryParam(newQuery); + }; + + const clearSearch = () => { + setSearchQuery(''); + debouncedUpdateQueryParam(''); + }; + + const toggleCategory = (category: LibraryCategoryFilter) => { + if (category === 'all') { + updateParams({ category: null, categoryValue: null, group: null }); + return; + } + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const selectCategoryValue = (category: LibraryCategoryFilter, value: string) => { + updateParams({ category, categoryValue: value, group: null }); + }; + + return ( + + ); +}; + +export default LibrarySidebar; 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 && ( - {getBreadcrumbs(currentGroupPath).map((crumb, index, array) => { - const isLast = index === array.length - 1; - return ( - - - {isLast ? ( - {crumb.name} - ) : ( - - )} - - ); - })} + {(loading || isSyncing) && ( +
+
-
- )} - {currentSeriesAuthorGroup && ( - - )} - {showBookshelf && - (libraryBooks.some((book) => !book.deletedAt) ? ( -
-
- - + )} + {currentGroupPath && ( +
+
+ + {getBreadcrumbs(currentGroupPath).map((crumb, index, array) => { + const isLast = index === array.length - 1; + return ( + + + {isLast ? ( + {crumb.name} + ) : ( + + )} + + ); + })}
- ) : ( -
- - -
- ))} + )} + {currentSeriesAuthorGroup && ( + + )} + {showBookshelf && + (libraryBooks.some((book) => !book.deletedAt) ? ( +
+
+ + +
+
+ ) : ( +
+ + +
+ ))} +
{showDetailsBook && ( + LIBRARY_CATEGORY_FILTERS.includes(value as LibraryCategoryFilter) + ? (value as LibraryCategoryFilter) + : 'all'; + +const getPublishedYear = (book: Book) => + typeof book.metadata?.published === 'string' + ? book.metadata.published.match(/\d{4}/)?.[0] + : undefined; + +const getSubjects = (book: Book): string[] => { + const subject = book.metadata?.subject; + if (!subject) return []; + if (Array.isArray(subject)) return subject.map((item) => `${item}`.trim()).filter(Boolean); + if (typeof subject === 'string') { + return subject + .split(/[,;,、]/u) + .map((item) => item.trim()) + .filter(Boolean); + } + return [formatAuthors(subject)].filter(Boolean); +}; + +export const getBookCategoryValues = (book: Book, category: LibraryCategoryFilter): string[] => { + switch (category) { + case 'author': + return parseAuthors(formatAuthors(book.author, book.primaryLanguage)); + case 'publisher': { + const publisher = formatPublisher(book.metadata?.publisher || '').trim(); + return publisher ? [publisher] : []; + } + case 'year': { + const year = getPublishedYear(book); + return year ? [year] : []; + } + case 'language': { + const language = formatLanguage(book.metadata?.language).trim(); + return language ? [language] : []; + } + case 'format': + return book.format ? [book.format] : []; + case 'subject': + return getSubjects(book); + case 'tag': + return book.tags?.map((tag) => tag.trim()).filter(Boolean) || []; + case 'all': + return []; + } +}; + +export const countCategoryValues = (books: Book[], category: LibraryCategoryFilter) => { + const counts = new Map(); + for (const book of books) { + for (const value of getBookCategoryValues(book, category)) { + counts.set(value, (counts.get(value) || 0) + 1); + } + } + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => a.value.localeCompare(b.value, navigator.language)); +}; diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index e0ef1244..09a26092 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -653,6 +653,7 @@ export type BookContextMenuItemId = | 'showDetails' | 'reviewInEpubEditor' | 'translateInEpubEditor' + | 'bilingual' | 'showInFinder' | 'searchGoodreads' | 'download' @@ -754,7 +755,7 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] = } ids.push('showDetails'); if (book.format?.toUpperCase() === 'EPUB') { - ids.push('reviewInEpubEditor', 'translateInEpubEditor'); + ids.push('bilingual', 'reviewInEpubEditor', 'translateInEpubEditor'); } ids.push('showInFinder', 'searchGoodreads'); if (book.uploadedAt && !book.downloadedAt) ids.push('download'); diff --git a/apps/readest-app/src/context/EnvContext.tsx b/apps/readest-app/src/context/EnvContext.tsx index 84bf6efc..fbbe9e30 100644 --- a/apps/readest-app/src/context/EnvContext.tsx +++ b/apps/readest-app/src/context/EnvContext.tsx @@ -9,6 +9,7 @@ import { initReplicaSync } from '@/services/sync/replicaSync'; import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore'; import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration'; import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist'; +import { isViewTransitionTimeoutError } from '@/utils/error'; interface EnvContextType { envConfig: EnvConfigType; @@ -40,14 +41,33 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => { console.warn('replica sync init failed', err); } }); - window.addEventListener('error', (e) => { + const handleError = (e: ErrorEvent) => { if (e.message === 'ResizeObserver loop limit exceeded') { e.stopImmediatePropagation(); e.preventDefault(); return true; } + if (isViewTransitionTimeoutError(e.error)) { + e.stopImmediatePropagation(); + e.preventDefault(); + return true; + } return false; - }); + }; + const handleUnhandledRejection = (e: PromiseRejectionEvent) => { + if (isViewTransitionTimeoutError(e.reason)) { + e.stopImmediatePropagation(); + e.preventDefault(); + return true; + } + return false; + }; + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleUnhandledRejection); + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + }; }, [envConfig]); const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]); diff --git a/apps/readest-app/src/hooks/useAppRouter.ts b/apps/readest-app/src/hooks/useAppRouter.ts index 3712f0fd..4600e051 100644 --- a/apps/readest-app/src/hooks/useAppRouter.ts +++ b/apps/readest-app/src/hooks/useAppRouter.ts @@ -2,7 +2,12 @@ import { useEnv } from '@/context/EnvContext'; import { useRouter } from 'next/navigation'; import { useTransitionRouter } from 'next-view-transitions'; -export const useAppRouter = () => { +const isWebDevMode = + process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web'; + +const usePlainAppRouter = () => useRouter(); + +const useTransitionAppRouter = () => { const { appService } = useEnv(); const transitionRouter = useTransitionRouter(); const plainRouter = useRouter(); @@ -15,3 +20,5 @@ export const useAppRouter = () => { // seen on unsupported webviews (Sentry READEST-9). return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter; }; + +export const useAppRouter = isWebDevMode ? usePlainAppRouter : useTransitionAppRouter; diff --git a/apps/readest-app/src/hooks/useLongPress.ts b/apps/readest-app/src/hooks/useLongPress.ts index 93d25ae8..cb140dd7 100644 --- a/apps/readest-app/src/hooks/useLongPress.ts +++ b/apps/readest-app/src/hooks/useLongPress.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; interface UseLongPressOptions { onTap?: () => void; onLongPress?: () => void; - onContextMenu?: () => void; + onContextMenu?: (event: React.MouseEvent) => void; onCancel?: () => void; threshold?: number; moveThreshold?: number; @@ -149,7 +149,7 @@ export const useLongPress = ( e.preventDefault(); e.stopPropagation(); setTimeout(() => { - onContextMenu(); + onContextMenu(e); }, 100); } reset(); diff --git a/apps/readest-app/src/services/bilingualEpubFilter.ts b/apps/readest-app/src/services/bilingualEpubFilter.ts new file mode 100644 index 00000000..2e4a0e4b --- /dev/null +++ b/apps/readest-app/src/services/bilingualEpubFilter.ts @@ -0,0 +1,471 @@ +import { makeSafeFilename } from '@/utils/misc'; +import { getBaseFilename } from '@/utils/path'; + +export type BilingualFilterLanguage = 'ja' | 'zh'; +export type BilingualFilterMode = 'auto' | 'style' | 'script'; + +export interface BilingualFilterOptions { + removeLanguage: BilingualFilterLanguage; + mode?: BilingualFilterMode; + removeUnknown?: boolean; +} + +export interface BilingualFilterFileStats { + path: string; + paragraphs: number; + removed: number; + kept: number; + unknown: number; + anchorsMoved: number; + byLanguage: Record; +} + +export interface BilingualFilterStats { + filesSeen: number; + htmlFiles: number; + changedFiles: number; + paragraphs: number; + removed: number; + kept: number; + unknown: number; + anchorsMoved: number; + fileStats: BilingualFilterFileStats[]; +} + +export interface BilingualFilterResult { + file: File; + filename: string; + title: string; + stats: BilingualFilterStats; +} + +const HTML_EXTENSIONS = ['.html', '.htm', '.xhtml']; +const TEXT_BLOCK_RE = /]*>.*?<\/p>/gis; +const BODY_END_RE = /<\/body\s*>/i; +const ANCHOR_RE = + /]*(?:\bid\s*=\s*['"][^'"]+['"]|\bname\s*=\s*['"][^'"]+['"]))[^>]*>\s*<\/a\s*>/gis; +const TAG_RE = /<[^>]+>/g; +const OPF_EXT = '.opf'; + +const makeEmptyFileStats = (path: string): BilingualFilterFileStats => ({ + path, + paragraphs: 0, + removed: 0, + kept: 0, + unknown: 0, + anchorsMoved: 0, + byLanguage: { ja: 0, zh: 0, unknown: 0 }, +}); + +const makeEmptyRunStats = (): BilingualFilterStats => ({ + filesSeen: 0, + htmlFiles: 0, + changedFiles: 0, + paragraphs: 0, + removed: 0, + kept: 0, + unknown: 0, + anchorsMoved: 0, + fileStats: [], +}); + +const isHtmlPath = (path: string) => { + const lower = path.toLowerCase(); + return HTML_EXTENSIONS.some((ext) => lower.endsWith(ext)); +}; + +const decodeHtmlEntities = (text: string) => { + if (typeof document !== 'undefined') { + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; + } + return text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +}; + +const stripTags = (fragment: string) => decodeHtmlEntities(fragment.replace(TAG_RE, '')).trim(); + +const getOpeningTag = (fragment: string) => fragment.match(/^]*>/is)?.[0] ?? ''; + +const isImageOrEmptyParagraph = (fragment: string) => { + if (stripTags(fragment)) return false; + return /<(img|svg|math)\b/i.test(fragment); +}; + +const getStyleAttribute = (openingTag: string) => + openingTag.match(/\bstyle\s*=\s*(["'])(.*?)\1/is)?.[2] ?? ''; + +const getClassAttribute = (openingTag: string) => + openingTag.match(/\bclass\s*=\s*(["'])(.*?)\1/is)?.[2] ?? ''; + +const hasGrayColor = (style: string) => { + const compact = style.replace(/\s+/g, '').toLowerCase(); + return ( + /(^|;)color:(gray|grey)(;|$)/i.test(style) || + compact.includes('color:#808080') || + compact.includes('color:#888') || + compact.includes('color:rgb(128,128,128)') || + compact.includes('color:rgba(128,128,128,') + ); +}; + +const isProbablyJapaneseByStyle = (fragment: string) => { + const openingTag = getOpeningTag(fragment); + const style = getStyleAttribute(openingTag); + const className = getClassAttribute(openingTag).toLowerCase(); + return ( + /\bopacity\s*:\s*0(?:\.40*|\.4|\.5|\.50*)\b/i.test(style) || + hasGrayColor(style) || + /\b(japanese|source|original|src|ja|jp)\b/.test(className) + ); +}; + +const isProbablyChineseByStyle = (fragment: string) => { + const openingTag = getOpeningTag(fragment); + const className = getClassAttribute(openingTag).toLowerCase(); + return /\b(chinese|translation|translated|target|zh|cn)\b/.test(className); +}; + +const scriptCounts = (text: string) => { + const counts = { hiragana: 0, katakana: 0, kana: 0, cjk: 0, ascii: 0 }; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + if (code >= 0x3040 && code <= 0x309f) { + counts.hiragana += 1; + counts.kana += 1; + } else if ( + (code >= 0x30a0 && code <= 0x30ff) || + (code >= 0x31f0 && code <= 0x31ff) || + (code >= 0xff66 && code <= 0xff9d) + ) { + counts.katakana += 1; + counts.kana += 1; + } else if ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3400 && code <= 0x4dbf) || + (code >= 0xf900 && code <= 0xfaff) + ) { + counts.cjk += 1; + } else if (/^[a-z]$/i.test(ch)) { + counts.ascii += 1; + } + } + return counts; +}; + +const classifyByScript = (fragment: string): BilingualFilterLanguage | 'unknown' => { + const text = stripTags(fragment); + if (!text) return 'unknown'; + + const counts = scriptCounts(text); + const meaningful = counts.kana + counts.cjk + counts.ascii; + if (meaningful === 0) return 'unknown'; + + if (counts.kana >= 2) return 'ja'; + if (counts.kana === 1 && counts.cjk <= 2) return 'ja'; + if (counts.cjk >= 2 && counts.kana === 0) return 'zh'; + + return 'unknown'; +}; + +const classifyParagraph = ( + fragment: string, + mode: BilingualFilterMode, +): BilingualFilterLanguage | 'unknown' => { + if (isImageOrEmptyParagraph(fragment)) return 'unknown'; + if (mode === 'auto' || mode === 'style') { + if (isProbablyJapaneseByStyle(fragment)) return 'ja'; + if (isProbablyChineseByStyle(fragment)) return 'zh'; + } + if (mode === 'style') { + return stripTags(fragment) ? 'zh' : 'unknown'; + } + return classifyByScript(fragment); +}; + +const looksLikeStyleBilingual = (text: string) => { + const paragraphs = text.match(TEXT_BLOCK_RE) ?? []; + if (paragraphs.length < 10) return false; + const textBlocks = paragraphs.filter((paragraph) => stripTags(paragraph)).length; + if (textBlocks === 0) return false; + const styled = paragraphs.filter(isProbablyJapaneseByStyle).length; + const ratio = styled / textBlocks; + return styled >= 5 && ratio >= 0.15 && ratio <= 0.85; +}; + +const extractEmptyAnchors = (fragment: string) => fragment.match(ANCHOR_RE) ?? []; + +const insertAnchorsIntoParagraph = (paragraph: string, anchors: string[]) => { + if (anchors.length === 0) return paragraph; + const insertion = anchors.join(''); + const match = paragraph.match(/^]*>/is); + if (!match) return insertion + paragraph; + const index = match[0].length; + return paragraph.slice(0, index) + insertion + paragraph.slice(index); +}; + +const insertPendingAnchorsBeforeBodyEnd = (text: string, anchors: string[]) => { + if (anchors.length === 0) return text; + const fallback = `

${anchors.join('')}

\n`; + const match = text.match(BODY_END_RE); + if (!match || match.index === undefined) return `${text}\n${fallback}`; + return text.slice(0, match.index) + fallback + text.slice(match.index); +}; + +const shouldRemove = ( + language: BilingualFilterLanguage | 'unknown', + removeLanguage: BilingualFilterLanguage, + removeUnknown: boolean, +) => language === removeLanguage || (removeUnknown && language === 'unknown'); + +const filterHtmlText = (text: string, options: Required, path: string) => { + const stats = makeEmptyFileStats(path); + const output: string[] = []; + let pendingAnchors: string[] = []; + let position = 0; + let changed = false; + const mode = options.mode === 'auto' && looksLikeStyleBilingual(text) ? 'style' : options.mode; + + for (const match of text.matchAll(TEXT_BLOCK_RE)) { + if (match.index === undefined) continue; + output.push(text.slice(position, match.index)); + + let paragraph = match[0]; + const language = classifyParagraph(paragraph, mode); + stats.paragraphs += 1; + stats.byLanguage[language] += 1; + + if (shouldRemove(language, options.removeLanguage, options.removeUnknown)) { + const anchors = extractEmptyAnchors(paragraph); + pendingAnchors = [...pendingAnchors, ...anchors]; + stats.removed += 1; + stats.anchorsMoved += anchors.length; + changed = true; + } else { + if (language === 'unknown') stats.unknown += 1; + stats.kept += 1; + if (pendingAnchors.length > 0) { + paragraph = insertAnchorsIntoParagraph(paragraph, pendingAnchors); + pendingAnchors = []; + changed = true; + } + output.push(paragraph); + } + + position = match.index + match[0].length; + } + + output.push(text.slice(position)); + let newText = output.join(''); + if (pendingAnchors.length > 0) { + newText = insertPendingAnchorsBeforeBodyEnd(newText, pendingAnchors); + changed = true; + } + + return { text: changed ? newText : text, stats }; +}; + +const escapeXml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const decodeText = (data: Uint8Array) => { + const encodings = ['utf-8', 'shift_jis', 'gb18030']; + for (const encoding of encodings) { + try { + const decoder = new TextDecoder(encoding, { fatal: true }); + return decoder.decode(data); + } catch { + // Try the next common EPUB encoding. + } + } + return new TextDecoder('utf-8').decode(data); +}; + +const setDocumentLanguageAndTitle = (text: string, title: string, language: string) => { + let updated = text; + if (/]*>.*?<\/title>/is.test(updated)) { + updated = updated.replace(/]*>.*?<\/title>/is, `${escapeXml(title)}`); + } + if (/\bxml:lang\s*=\s*["'][^"']*["']/i.test(updated)) { + updated = updated.replace(/\bxml:lang\s*=\s*(["'])[^"']*\1/i, `xml:lang="${language}"`); + } + if (/\blang\s*=\s*["'][^"']*["']/i.test(updated)) { + updated = updated.replace(/\blang\s*=\s*(["'])[^"']*\1/i, `lang="${language}"`); + } else { + updated = updated.replace(/ { + const match = text.match(/]*>(.*?)<\/dc:title>/is); + return match ? stripTags(match[1] ?? '') : ''; +}; + +const getVariantInfo = ( + sourceName: string, + removeLanguage: BilingualFilterLanguage, + metadataTitle?: string, +) => { + const sourceTitle = metadataTitle?.trim() || getBaseFilename(sourceName); + const suffix = removeLanguage === 'ja' ? '简中译本' : '日文原文版'; + const language = removeLanguage === 'ja' ? 'zh-CN' : 'ja'; + const title = `${sourceTitle}(${suffix})`; + const filename = `${makeSafeFilename(`${sourceTitle}_${suffix}`)}.epub`; + const identifierSuffix = removeLanguage === 'ja' ? 'readest-no-ja' : 'readest-no-zh'; + return { title, filename, language, identifierSuffix }; +}; + +const patchOpfMetadata = ( + text: string, + variant: ReturnType, + removeLanguage: BilingualFilterLanguage, +) => { + let updated = text; + const title = escapeXml(variant.title); + if (/]*>.*?<\/dc:title>/is.test(updated)) { + updated = updated.replace( + /]*)>.*?<\/dc:title>/is, + `${title}`, + ); + } + if (/]*>.*?<\/dc:language>/is.test(updated)) { + updated = updated.replace( + /]*)>.*?<\/dc:language>/is, + `${variant.language}`, + ); + } + if (/]*>.*?<\/dc:identifier>/is.test(updated)) { + updated = updated.replace( + /]*)>(.*?)<\/dc:identifier>/gis, + (_match, attrs: string, value: string) => { + const trimmed = value.trim(); + const suffix = `:${variant.identifierSuffix}`; + const nextValue = trimmed.includes(suffix) ? trimmed : `${trimmed}${suffix}`; + return `${escapeXml(nextValue)}`; + }, + ); + } else { + updated = updated.replace( + /<\/metadata\s*>/i, + `readest:${removeLanguage}:${Date.now()}`, + ); + } + updated = updated.replace( + /]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is, + `${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}`, + ); + return updated; +}; + +const updateRunStats = (runStats: BilingualFilterStats, fileStats: BilingualFilterFileStats) => { + runStats.fileStats.push(fileStats); + runStats.paragraphs += fileStats.paragraphs; + runStats.removed += fileStats.removed; + runStats.kept += fileStats.kept; + runStats.unknown += fileStats.unknown; + runStats.anchorsMoved += fileStats.anchorsMoved; +}; + +export async function filterBilingualEpubFile( + sourceFile: File, + filterOptions: BilingualFilterOptions, +): Promise { + const options: Required = { + mode: 'auto', + removeUnknown: false, + ...filterOptions, + }; + const { + BlobReader, + BlobWriter, + TextReader, + Uint8ArrayReader, + Uint8ArrayWriter, + ZipReader, + ZipWriter, + } = await import('@zip.js/zip.js'); + + const reader = new ZipReader(new BlobReader(sourceFile)); + const writer = new ZipWriter(new BlobWriter('application/epub+zip')); + const runStats = makeEmptyRunStats(); + + try { + const entries = await reader.getEntries(); + const readableEntries = entries.filter(isReadableZipEntry); + const opfEntry = readableEntries.find((entry) => + entry.filename.toLowerCase().endsWith(OPF_EXT), + ); + const opfTitle = opfEntry + ? extractOpfTitle(decodeText(await opfEntry.getData(new Uint8ArrayWriter()))) + : ''; + const variant = getVariantInfo(sourceFile.name, options.removeLanguage, opfTitle); + const seen = new Set(); + const mimetype = entries.find((entry) => entry.filename.toLowerCase() === 'mimetype'); + if (mimetype) { + await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 }); + seen.add(mimetype.filename); + runStats.filesSeen += 1; + } + + for (const entry of readableEntries) { + if (seen.has(entry.filename)) continue; + seen.add(entry.filename); + runStats.filesSeen += 1; + + const lowerName = entry.filename.toLowerCase(); + const rawData = await entry.getData(new Uint8ArrayWriter()); + let outData: Uint8Array | string = rawData; + + if (isHtmlPath(lowerName)) { + runStats.htmlFiles += 1; + const originalText = decodeText(rawData); + const filtered = filterHtmlText(originalText, options, entry.filename); + const patchedText = setDocumentLanguageAndTitle( + filtered.text, + variant.title, + variant.language, + ); + updateRunStats(runStats, filtered.stats); + if (patchedText !== originalText) runStats.changedFiles += 1; + outData = patchedText; + } else if (lowerName.endsWith(OPF_EXT)) { + const originalText = decodeText(rawData); + const patchedText = patchOpfMetadata(originalText, variant, options.removeLanguage); + if (patchedText !== originalText) runStats.changedFiles += 1; + outData = patchedText; + } + + const writerReader = + typeof outData === 'string' + ? new TextReader(outData) + : new Uint8ArrayReader(outData as Uint8Array); + await writer.add(entry.filename, writerReader, zipOptionsForEntry(entry)); + } + + const blob = await writer.close(); + const file = new File([blob], variant.filename, { type: 'application/epub+zip' }); + return { file, filename: variant.filename, title: variant.title, stats: runStats }; + } finally { + await reader.close(); + } +} + +const isReadableZipEntry = ( + entry: import('@zip.js/zip.js').Entry, +): entry is import('@zip.js/zip.js').FileEntry => + !entry.directory && typeof entry.getData === 'function'; + +const zipOptionsForEntry = (entry: import('@zip.js/zip.js').Entry) => + entry.filename.toLowerCase() === 'mimetype' ? { level: 0 } : {}; diff --git a/apps/readest-app/src/utils/error.ts b/apps/readest-app/src/utils/error.ts index 6039bb97..77e6f8b0 100644 --- a/apps/readest-app/src/utils/error.ts +++ b/apps/readest-app/src/utils/error.ts @@ -1,4 +1,17 @@ +const VIEW_TRANSITION_TIMEOUT = 'Transition was aborted because of timeout in DOM update'; + +export const isViewTransitionTimeoutError = (error: unknown) => { + if (process.env['NODE_ENV'] !== 'development') return false; + return ( + error instanceof Error && + error.name === 'TimeoutError' && + error.message === VIEW_TRANSITION_TIMEOUT + ); +}; + export const handleGlobalError = (e: Error) => { + if (isViewTransitionTimeoutError(e)) return; + const isChunkError = e?.message?.includes('Loading chunk'); if (!isChunkError) {