5 Commits

Author SHA1 Message Date
Codex 21cad7ec09 Fix web dev router without view transitions 2026-07-09 00:48:30 +08:00
Codex dc7137d331 Add bilingual action to book menu 2026-07-09 00:45:12 +08:00
Codex c4621a273e Add web bookshelf context menu 2026-07-09 00:38:04 +08:00
Codex c410b405b9 Fix web dev view transition timeout 2026-07-09 00:31:30 +08:00
Codex 94c35f999b Add bilingual EPUB filter 2026-07-08 23:00:44 +08:00
14 changed files with 883 additions and 40 deletions
@@ -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": "取消",
+9 -7
View File
@@ -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
+22 -2
View File
@@ -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]);
+8 -1
View File
@@ -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;
+2 -2
View File
@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
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 } : {};
+13
View File
@@ -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) {