forked from akai/readest
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21cad7ec09 | |||
| dc7137d331 | |||
| c4621a273e | |||
| c410b405b9 | |||
| 94c35f999b |
@@ -69,6 +69,7 @@
|
||||
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
|
||||
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
|
||||
"Recently read": "Recently read",
|
||||
"Bilingual": "Bilingual",
|
||||
"Show recently read": "Show recently read",
|
||||
"Your books will appear here": "Your books will appear here",
|
||||
"{{count}} results_one": "{{count}} result",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"Apply": "应用",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Bilingual": "双语拆分",
|
||||
"Book": "书籍",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"Apply": "應用",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Bilingual": "雙語拆分",
|
||||
"Book": "書籍",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
|
||||
@@ -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 = (
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
@@ -144,13 +152,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
|
||||
) : null}
|
||||
</head>
|
||||
<body>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
<body>{shouldUseViewTransitions ? <ViewTransitions>{app}</ViewTransitions> : app}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<BilingualFilterAlertProps> = ({
|
||||
bookTitle,
|
||||
safeAreaBottom,
|
||||
processing,
|
||||
onCancel,
|
||||
onFilter,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const divRef = useKeyDownActions({ onCancel });
|
||||
const [mode, setMode] = useState<BilingualFilterMode>('auto');
|
||||
const [removeUnknown, setRemoveUnknown] = useState(false);
|
||||
|
||||
const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => {
|
||||
if (processing) return;
|
||||
onFilter({ removeLanguage, mode, removeUnknown });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className='fixed bottom-0 left-0 right-0 z-50 flex justify-center px-3'
|
||||
style={{ paddingBottom: `${safeAreaBottom + 16}px` }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-base-content/10 bg-base-200/95 flex w-full max-w-xl flex-col gap-4 rounded-2xl border p-4 shadow-lg backdrop-blur-sm',
|
||||
processing && 'pointer-events-none opacity-80',
|
||||
)}
|
||||
>
|
||||
<div className='relative flex min-w-0 items-center justify-center gap-2 pr-8'>
|
||||
<LuLanguages className='size-5 shrink-0' />
|
||||
<div className='min-w-0 truncate text-center text-sm font-medium'>
|
||||
{_('Bilingual EPUB')}: {bookTitle}
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
'absolute right-0 flex items-center justify-center rounded-full p-1.5',
|
||||
'text-base-content/70 transition-colors hover:text-base-content',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
aria-label={_('Cancel')}
|
||||
disabled={processing}
|
||||
>
|
||||
<PiX className='size-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
|
||||
<label className='flex min-w-0 items-center gap-2'>
|
||||
<span className='text-neutral-content shrink-0 text-xs'>{_('Detection')}</span>
|
||||
<select
|
||||
className='select select-bordered select-sm min-w-0 flex-1'
|
||||
value={mode}
|
||||
disabled={processing}
|
||||
onChange={(event) => setMode(event.target.value as BilingualFilterMode)}
|
||||
>
|
||||
<option value='auto'>{_('Auto')}</option>
|
||||
<option value='style'>{_('Style')}</option>
|
||||
<option value='script'>{_('Script')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 justify-self-start sm:justify-self-end'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={removeUnknown}
|
||||
disabled={processing}
|
||||
onChange={(event) => setRemoveUnknown(event.target.checked)}
|
||||
/>
|
||||
<span className='text-sm'>{_('Remove uncertain text')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('ja')}
|
||||
>
|
||||
{_('Keep Chinese')}
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-secondary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('zh')}
|
||||
>
|
||||
{_('Keep Japanese')}
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' disabled={processing} onClick={onCancel}>
|
||||
{processing ? _('Processing...') : _('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilingualFilterAlert;
|
||||
@@ -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,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showStatusAlert, setShowStatusAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false);
|
||||
const [bilingualFilterBook, setBilingualFilterBook] = useState<Book | null>(null);
|
||||
const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
|
||||
const abortDeletionRef = useRef(false);
|
||||
@@ -452,6 +460,112 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
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> | 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 +792,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
);
|
||||
|
||||
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 +910,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleBilingualFilterBook={showBilingualFilterBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
@@ -816,6 +936,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
showBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
],
|
||||
@@ -889,9 +1010,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onStatus={showStatusSelection}
|
||||
onBilingualFilter={showBilingualFilterSelection}
|
||||
onSend={sendSelectedBook}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
bilingualFilterEnabled={bilingualFilterEnabled}
|
||||
/>
|
||||
)}
|
||||
{showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && (
|
||||
<BilingualFilterAlert
|
||||
bookTitle={(bilingualFilterBook || selectedBilingualBook)?.title || ''}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
processing={bilingualFilterProcessing}
|
||||
onCancel={closeBilingualFilterSelection}
|
||||
onFilter={runBilingualFilter}
|
||||
/>
|
||||
)}
|
||||
{showGroupingModal && selectedBooks.length > 0 && (
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { 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';
|
||||
@@ -24,6 +27,17 @@ import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
import { useOpenBook } from '../hooks/useOpenBook';
|
||||
|
||||
type WebContextMenuItem = {
|
||||
text: string;
|
||||
action: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type WebContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
items: WebContextMenuItem[];
|
||||
} | null;
|
||||
|
||||
export const generateBookshelfItems = (
|
||||
books: Book[],
|
||||
parentGroupName: string,
|
||||
@@ -103,6 +117,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleBilingualFilterBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
@@ -121,6 +136,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
@@ -128,6 +144,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
|
||||
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
|
||||
|
||||
const showBookDetailsModal = useCallback(async (book: Book) => {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -157,8 +174,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
[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;
|
||||
@@ -166,7 +182,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
// 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<BookContextMenuItemId, MenuItemOptions> = {
|
||||
const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
|
||||
select: {
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
@@ -214,6 +230,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
},
|
||||
bilingual: {
|
||||
text: _('Bilingual'),
|
||||
action: async () => {
|
||||
handleBilingualFilterBook(book);
|
||||
},
|
||||
},
|
||||
showInFinder: {
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
@@ -254,16 +276,22 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
};
|
||||
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 () => {
|
||||
@@ -293,8 +321,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
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
|
||||
@@ -330,14 +362,28 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
// 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(
|
||||
@@ -348,9 +394,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
onTap: () => {
|
||||
handleOpenItem();
|
||||
},
|
||||
onContextMenu: () => {
|
||||
onContextMenu: (event) => {
|
||||
if (appService?.hasContextMenu) {
|
||||
handleContextMenu();
|
||||
} else if (!appService?.isMobileApp) {
|
||||
handleContextMenu(event);
|
||||
} else if (appService?.isAndroidApp) {
|
||||
handleSelectItem();
|
||||
}
|
||||
@@ -380,6 +428,29 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
{webContextMenu && (
|
||||
<>
|
||||
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
|
||||
<Menu
|
||||
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
|
||||
style={{ left: webContextMenu.x, top: webContextMenu.y }}
|
||||
onCancel={() => setWebContextMenu(null)}
|
||||
>
|
||||
{webContextMenu.items.map((menuItem) => (
|
||||
<MenuItem
|
||||
key={menuItem.text}
|
||||
label={menuItem.text}
|
||||
noIcon
|
||||
transient
|
||||
onClick={() => {
|
||||
setWebContextMenu(null);
|
||||
void menuItem.action();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'visible-focus-inset-2 group',
|
||||
|
||||
@@ -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<SelectModeActionsProps> = ({
|
||||
@@ -42,9 +44,11 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onBilingualFilter,
|
||||
onSend,
|
||||
onDelete,
|
||||
onCancel,
|
||||
bilingualFilterEnabled = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
@@ -110,13 +114,21 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onBilingualFilter}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!bilingualFilterEnabled && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuLanguages />
|
||||
<div>{_('Bilingual')}</div>
|
||||
</button>
|
||||
{sendEnabled && (
|
||||
<button
|
||||
onClick={onSend}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Wraps to the start of the second row on narrow viewports.
|
||||
'max-[500px]:col-start-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
@@ -128,12 +140,6 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Without Send (Linux/Windows/web), Delete needs an explicit
|
||||
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
|
||||
// under the 4-col grid. With Send present, the layout is
|
||||
// {Send, Delete, Cancel} starting at col-start-1, so Delete
|
||||
// naturally lands in col-start-2 without an override.
|
||||
!sendEnabled && 'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -651,6 +651,7 @@ export type BookContextMenuItemId =
|
||||
| 'markAbandoned'
|
||||
| 'clearStatus'
|
||||
| 'showDetails'
|
||||
| 'bilingual'
|
||||
| 'showInFinder'
|
||||
| 'searchGoodreads'
|
||||
| 'download'
|
||||
@@ -750,7 +751,9 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
|
||||
) {
|
||||
ids.push('clearStatus');
|
||||
}
|
||||
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
|
||||
ids.push('showDetails');
|
||||
if (book.format === 'EPUB') ids.push('bilingual');
|
||||
ids.push('showInFinder', 'searchGoodreads');
|
||||
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
|
||||
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
|
||||
// Share is offered for any local-or-uploaded book; the dialog uploads first
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<BilingualFilterLanguage | 'unknown', number>;
|
||||
}
|
||||
|
||||
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\b[^>]*>.*?<\/p>/gis;
|
||||
const BODY_END_RE = /<\/body\s*>/i;
|
||||
const ANCHOR_RE =
|
||||
/<a\b(?=[^>]*(?:\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(/^<p\b[^>]*>/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(/^<p\b[^>]*>/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 = `<p style="display:none;">${anchors.join('')}</p>\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<BilingualFilterOptions>, 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, '"')
|
||||
.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\b[^>]*>.*?<\/title>/is.test(updated)) {
|
||||
updated = updated.replace(/<title\b[^>]*>.*?<\/title>/is, `<title>${escapeXml(title)}</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(/<html\b/i, `<html lang="${language}"`);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
const extractOpfTitle = (text: string) => {
|
||||
const match = text.match(/<dc:title\b[^>]*>(.*?)<\/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<typeof getVariantInfo>,
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
) => {
|
||||
let updated = text;
|
||||
const title = escapeXml(variant.title);
|
||||
if (/<dc:title\b[^>]*>.*?<\/dc:title>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:title\b([^>]*)>.*?<\/dc:title>/is,
|
||||
`<dc:title$1>${title}</dc:title>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:language\b[^>]*>.*?<\/dc:language>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:language\b([^>]*)>.*?<\/dc:language>/is,
|
||||
`<dc:language$1>${variant.language}</dc:language>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:identifier\b[^>]*>.*?<\/dc:identifier>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:identifier\b([^>]*)>(.*?)<\/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 `<dc:identifier${attrs}>${escapeXml(nextValue)}</dc:identifier>`;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updated = updated.replace(
|
||||
/<\/metadata\s*>/i,
|
||||
`<dc:identifier id="readest-filter-id">readest:${removeLanguage}:${Date.now()}</dc:identifier></metadata>`,
|
||||
);
|
||||
}
|
||||
updated = updated.replace(
|
||||
/<meta\b([^>]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is,
|
||||
`<meta$1>${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}</meta>`,
|
||||
);
|
||||
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<BilingualFilterResult> {
|
||||
const options: Required<BilingualFilterOptions> = {
|
||||
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<string>();
|
||||
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 } : {};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user