Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14d1b35d87 | |||
| 21cad7ec09 | |||
| dc7137d331 | |||
| c4621a273e | |||
| c410b405b9 | |||
| 94c35f999b |
@@ -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",
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
"Apply": "应用",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Bilingual": "双语拆分",
|
||||
"Book": "书籍",
|
||||
"All Books": "全部书籍",
|
||||
"Categories": "分类",
|
||||
"Category": "类别",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章节",
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
"Apply": "應用",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Bilingual": "雙語拆分",
|
||||
"Book": "書籍",
|
||||
"All Books": "全部書籍",
|
||||
"Categories": "分類",
|
||||
"Category": "類別",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章節",
|
||||
|
||||
@@ -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;
|
||||
@@ -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<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
categoryFilter?: LibraryCategoryFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +177,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
categoryFilter = 'all',
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -180,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
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<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);
|
||||
@@ -241,10 +253,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
[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<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 +806,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 +924,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleBilingualFilterBook={showBilingualFilterBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
@@ -816,6 +950,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
showBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
],
|
||||
@@ -889,9 +1024,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',
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
PiBooks,
|
||||
PiCalendarBlank,
|
||||
PiDotsThreeCircle,
|
||||
PiGear,
|
||||
PiGlobeHemisphereEast,
|
||||
PiHouseLine,
|
||||
PiPlus,
|
||||
PiSelectionAll,
|
||||
PiSelectionAllFill,
|
||||
PiStackSimple,
|
||||
PiTag,
|
||||
PiUser,
|
||||
} from 'react-icons/pi';
|
||||
import { MdBusiness, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineMenu } from 'react-icons/md';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import ImportMenu from './ImportMenu';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ViewMenu from './ViewMenu';
|
||||
import type { Book } from '@/types/book';
|
||||
import {
|
||||
countCategoryValues,
|
||||
ensureLibraryCategoryFilter,
|
||||
type CategoryValue,
|
||||
type LibraryCategoryFilter,
|
||||
} from '../utils/categoryFilters';
|
||||
|
||||
interface LibrarySidebarProps {
|
||||
books: Book[];
|
||||
isSelectMode: boolean;
|
||||
onPullLibrary: () => 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<LibrarySidebarProps> = ({
|
||||
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<Set<LibraryCategoryFilter>>(
|
||||
() => 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<string, string | null>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<aside
|
||||
className={clsx(
|
||||
'library-sidebar bg-base-200/80 border-base-300/70 hidden h-full w-[248px] shrink-0 flex-col border-r md:flex',
|
||||
'pt-[max(env(safe-area-inset-top),0px)]',
|
||||
)}
|
||||
>
|
||||
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
|
||||
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
|
||||
<MdOutlineMenu className='text-base-content/70 h-5 w-5 shrink-0' />
|
||||
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
|
||||
</div>
|
||||
|
||||
<div className='relative mb-3 flex h-8 items-center'>
|
||||
<span className='text-base-content/45 absolute left-3'>
|
||||
<FaSearch className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
placeholder={_('Search Books...')}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input input-sm bg-base-100 border-base-300 h-8 w-full rounded-md ps-9 pe-8',
|
||||
'text-sm placeholder:text-base-content/45 focus:outline-none',
|
||||
)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={clearSearch}
|
||||
className='text-base-content/40 hover:text-base-content/70 absolute right-2'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className='min-h-0 flex-1 overflow-y-auto pb-3' aria-label={_('Categories')}>
|
||||
<div className='space-y-1'>
|
||||
{categories.map(({ id, label, count, Icon, values }) => {
|
||||
const active = (activeCategory || 'all') === id && !activeCategoryValue;
|
||||
const expanded = expandedCategories.has(id);
|
||||
return (
|
||||
<div key={id}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleCategory(id)}
|
||||
className={clsx(
|
||||
'flex h-9 w-full items-center gap-3 rounded-md px-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/85 hover:bg-base-300/55',
|
||||
)}
|
||||
>
|
||||
<Icon className='h-4.5 w-4.5 shrink-0' />
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
{id !== 'all' &&
|
||||
(expanded ? (
|
||||
<MdKeyboardArrowUp className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
) : (
|
||||
<MdKeyboardArrowDown className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
))}
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{id !== 'all' && expanded && values.length > 0 && (
|
||||
<div className='mt-1 space-y-1 pb-1 ps-7'>
|
||||
{values.map((item) => {
|
||||
const valueActive =
|
||||
activeCategory === id && activeCategoryValue === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => selectCategoryValue(id, item.value)}
|
||||
className={clsx(
|
||||
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm transition-colors',
|
||||
valueActive
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/80 hover:bg-base-300/45',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'h-4 w-0.5 rounded-full',
|
||||
valueActive ? 'bg-primary' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{item.value}</span>
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className='border-base-300/70 flex items-center gap-1 border-t pt-2'>
|
||||
<Dropdown
|
||||
label={_('Import Books')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiPlus role='none' size={iconSize18} />}
|
||||
>
|
||||
<ImportMenu
|
||||
onImportBooksFromFiles={onImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={onImportBooksFromDirectory}
|
||||
onImportBookFromUrl={onImportBookFromUrl}
|
||||
onOpenCatalogManager={onOpenCatalogManager}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
label={_('View Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
|
||||
>
|
||||
<ViewMenu />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Books')}
|
||||
title={_('Select Books')}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
{isSelectMode ? (
|
||||
<PiSelectionAllFill className='h-[18px] w-[18px]' />
|
||||
) : (
|
||||
<PiSelectionAll className='h-[18px] w-[18px]' />
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1' />
|
||||
<Dropdown
|
||||
label={_('Settings Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiGear role='none' size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu onPullLibrary={onPullLibrary} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibrarySidebar;
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -100,6 +100,8 @@ import {
|
||||
} from './utils/libraryUtils';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import LibrarySidebar from './components/LibrarySidebar';
|
||||
import { ensureLibraryCategoryFilter } from './utils/categoryFilters';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import LibraryEmptyState from './components/LibraryEmptyState';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
@@ -1592,133 +1594,161 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
aria-label={_('Your Library')}
|
||||
className={clsx(
|
||||
'library-page text-base-content full-height flex select-none flex-col overflow-hidden',
|
||||
'library-page text-base-content full-height flex select-none overflow-hidden',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full'
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
<LibrarySidebar
|
||||
books={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
/>
|
||||
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full md:hidden'
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={
|
||||
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
|
||||
}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{currentGroupPath && (
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
)}
|
||||
{currentGroupPath && (
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-y-1 px-4 py-2 text-base'>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
categoryFilter={categoryFilter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<NowPlayingBar isSelectMode={isSelectMode} />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Book } from '@/types/book';
|
||||
import { formatAuthors, formatLanguage, formatPublisher } from '@/utils/book';
|
||||
import { parseAuthors } from './libraryUtils';
|
||||
|
||||
export type LibraryCategoryFilter =
|
||||
| 'all'
|
||||
| 'author'
|
||||
| 'publisher'
|
||||
| 'year'
|
||||
| 'language'
|
||||
| 'format'
|
||||
| 'subject'
|
||||
| 'tag';
|
||||
|
||||
const LIBRARY_CATEGORY_FILTERS: LibraryCategoryFilter[] = [
|
||||
'all',
|
||||
'author',
|
||||
'publisher',
|
||||
'year',
|
||||
'language',
|
||||
'format',
|
||||
'subject',
|
||||
'tag',
|
||||
];
|
||||
|
||||
export type CategoryValue = {
|
||||
value: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const ensureLibraryCategoryFilter = (
|
||||
value: string | null | undefined,
|
||||
): LibraryCategoryFilter =>
|
||||
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<string, number>();
|
||||
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));
|
||||
};
|
||||
@@ -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