From d00f1e0defa7885e4ecb37fa09f16d364e76865b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 3 Nov 2025 12:47:26 +0800 Subject: [PATCH] feat(import): import files directly into the current book group (#2393) --- .../app/library/components/BookshelfItem.tsx | 20 +-- .../app/library/components/GroupingModal.tsx | 68 +++++----- .../app/library/hooks/useDragDropImport.ts | 113 ++++++++++++++++ apps/readest-app/src/app/library/page.tsx | 122 ++++-------------- apps/readest-app/src/services/appService.ts | 2 +- apps/readest-app/src/store/libraryStore.ts | 51 +++++++- 6 files changed, 224 insertions(+), 152 deletions(-) create mode 100644 apps/readest-app/src/app/library/hooks/useDragDropImport.ts diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 8fc7b8d3..9e619fd6 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -15,7 +15,7 @@ import { throttle } from '@/utils/throttle'; 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'; -import { Book, BookGroupType, BooksGroup } from '@/types/book'; +import { Book, BooksGroup } from '@/types/book'; import BookItem from './BookItem'; import GroupItem from './GroupItem'; @@ -50,24 +50,6 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt); }; -export const generateGroupsList = (items: Book[]): BookGroupType[] => { - return items - .sort((a, b) => b.updatedAt - a.updatedAt) - .reduce((acc: BookGroupType[], item: Book) => { - if (item.deletedAt) return acc; - if ( - item.groupId && - item.groupName && - item.groupId !== BOOK_UNGROUPED_ID && - item.groupName !== BOOK_UNGROUPED_NAME && - !acc.find((group) => group.id === item.groupId) - ) { - acc.push({ id: item.groupId, name: item.groupName }); - } - return acc; - }, []) as BookGroupType[]; -}; - interface BookshelfItemProps { mode: LibraryViewModeType; item: BookshelfItem; diff --git a/apps/readest-app/src/app/library/components/GroupingModal.tsx b/apps/readest-app/src/app/library/components/GroupingModal.tsx index 5a86749f..19a9245f 100644 --- a/apps/readest-app/src/app/library/components/GroupingModal.tsx +++ b/apps/readest-app/src/app/library/components/GroupingModal.tsx @@ -4,13 +4,12 @@ import { MdCheck } from 'react-icons/md'; import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi'; import { Book, BookGroupType } from '@/types/book'; -import { isMd5, md5Fingerprint } from '@/utils/md5'; +import { isMd5 } from '@/utils/md5'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { useLibraryStore } from '@/store/libraryStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; -import { generateGroupsList } from './BookshelfItem'; interface GroupingModalProps { libraryBooks: Book[]; @@ -27,16 +26,15 @@ const GroupingModal: React.FC = ({ }) => { const _ = useTranslation(); const { appService } = useEnv(); - const { setLibrary } = useLibraryStore(); - const groupsList = generateGroupsList(libraryBooks); + const { setLibrary, addGroup, getGroups, refreshGroups } = useLibraryStore(); + + const allGroups = getGroups(); const [showInput, setShowInput] = useState(false); const [editGroupName, setEditGroupName] = useState(_('Untitled Group')); const [selectedGroup, setSelectedGroup] = useState(null); - const [newGroups, setNewGroups] = useState([]); - const [allGroups, setAllGroups] = useState(groupsList); - const editorRef = useRef(null); + const editorRef = useRef(null); const iconSize = useResponsiveSize(16); const isSelectedBooksHasGroup = @@ -45,6 +43,20 @@ const GroupingModal: React.FC = ({ .map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId) .some((group) => group && group !== BOOK_UNGROUPED_NAME); + const generateNextUntitledGroupName = () => { + const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`); + const untitledGroupNumbers = allGroups + .map((group) => { + const match = group.name.match(untitledGroupPattern); + return match ? parseInt(match[1] || '0', 10) : null; + }) + .filter((num) => num !== null); + + const nextNumber = untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1; + + return `${_('Untitled Group')} ${nextNumber}`; + }; + const handleCreateGroup = () => { setShowInput(true); }; @@ -73,33 +85,11 @@ const GroupingModal: React.FC = ({ const handleConfirmCreateGroup = () => { const groupName = editGroupName.trim(); if (groupName) { - const newGroup = { id: md5Fingerprint(groupName), name: groupName }; - const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName); - if (existingGroupIndex > -1) { - newGroups.splice(existingGroupIndex, 1); - } - newGroups.push(newGroup); + const newGroup = addGroup(groupName); setSelectedGroup(newGroup); - setNewGroups(newGroups); - for (const newGroup of newGroups) { - const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id); - if (existingGroupIndex > -1) { - groupsList.splice(existingGroupIndex, 1); - } - groupsList.unshift(newGroup); - } - setAllGroups(groupsList); - const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`); - const untitledGroupNumbers = groupsList - .map((group) => { - const match = group.name.match(untitledGroupPattern); - return match ? parseInt(match[1] || '0', 10) : null; - }) - .filter((num) => num !== null); - const nextNumber = - untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1; - setEditGroupName(`${_('Untitled Group')} ${nextNumber}`); + const nextName = generateNextUntitledGroupName(); + setEditGroupName(nextName); setShowInput(false); } }; @@ -129,15 +119,21 @@ const GroupingModal: React.FC = ({ } }, [showInput]); + useEffect(() => { + refreshGroups(); + }, [refreshGroups]); + useEffect(() => { const groupIds = selectedBooks .map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId) .filter((groupId) => groupId); if (Array.from(new Set(groupIds)).length === 1) { - setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null); + setTimeout(() => { + const allGroups = getGroups(); + setSelectedGroup(allGroups.find((group) => group.id === groupIds[0]) || null); + }, 0); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedBooks]); + }, [libraryBooks, selectedBooks, getGroups]); return (
@@ -183,7 +179,7 @@ const GroupingModal: React.FC = ({ className={clsx( 'btn btn-ghost settings-content hover:bg-transparent', 'flex h-[1.3em] min-h-[1.3em] items-end p-0', - editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0', + editGroupName ? '' : 'btn-disabled !bg-opacity-0', )} onClick={() => handleConfirmCreateGroup()} > diff --git a/apps/readest-app/src/app/library/hooks/useDragDropImport.ts b/apps/readest-app/src/app/library/hooks/useDragDropImport.ts new file mode 100644 index 00000000..4e6f159c --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useDragDropImport.ts @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { impactFeedback } from '@tauri-apps/plugin-haptics'; +import { eventDispatcher } from '@/utils/event'; +import { SelectedFile } from '@/hooks/useFileSelector'; +import { isTauriAppPlatform } from '@/services/environment'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { useTranslation } from '@/hooks/useTranslation'; +import { BOOK_ACCEPT_FORMATS } from '@/services/constants'; +import { useSearchParams } from 'next/navigation'; + +export const useDragDropImport = () => { + const _ = useTranslation(); + const searchParams = useSearchParams(); + const group = searchParams?.get('group') || ''; + + const { appService } = useEnv(); + const [isDragging, setIsDragging] = useState(false); + + const handleDroppedFiles = async (files: File[] | string[]) => { + if (files.length === 0) return; + const supportedFiles = files.filter((file) => { + let fileExt; + if (typeof file === 'string') { + fileExt = file.split('.').pop()?.toLowerCase(); + } else { + fileExt = file.name.split('.').pop()?.toLowerCase(); + } + return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`); + }); + if (supportedFiles.length === 0) { + eventDispatcher.dispatch('toast', { + message: _('No supported files found. Supported formats: {{formats}}', { + formats: BOOK_ACCEPT_FORMATS, + }), + type: 'error', + }); + return; + } + + if (appService?.hasHaptics) { + impactFeedback('medium'); + } + + const selectedFiles = supportedFiles.map( + (file) => + ({ + file: typeof file === 'string' ? undefined : file, + path: typeof file === 'string' ? file : undefined, + }) as SelectedFile, + ); + eventDispatcher.dispatch('import-book-files', { files: selectedFiles, groupId: group }); + }; + + const handleDragOver = (event: React.DragEvent | DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (event: React.DragEvent | DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = async (event: React.DragEvent | DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + + if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) { + const files = Array.from(event.dataTransfer.files); + handleDroppedFiles(files); + } + }; + + useEffect(() => { + const libraryPage = document.querySelector('.library-page'); + if (!appService?.isMobile) { + libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener); + libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener); + libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener); + } + + if (isTauriAppPlatform()) { + const unlisten = getCurrentWebview().onDragDropEvent((event) => { + if (event.payload.type === 'over') { + setIsDragging(true); + } else if (event.payload.type === 'drop') { + setIsDragging(false); + handleDroppedFiles(event.payload.paths); + } else { + setIsDragging(false); + } + }); + return () => { + unlisten.then((fn) => fn()); + }; + } + + return () => { + if (!appService?.isMobile) { + libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener); + libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener); + libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [group]); + + return { isDragging }; +}; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index cb160f7d..59f62dd9 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -18,7 +18,6 @@ import { getFilename } from '@/utils/path'; import { parseOpenWithFiles } from '@/helpers/openWith'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater'; -import { BOOK_ACCEPT_FORMATS } from '@/services/constants'; import { impactFeedback } from '@tauri-apps/plugin-haptics'; import { getCurrentWebview } from '@tauri-apps/api/webview'; @@ -49,6 +48,7 @@ import { AboutWindow } from '@/components/AboutWindow'; import { BookDetailModal } from '@/components/metadata'; import { UpdaterWindow } from '@/components/UpdaterWindow'; import { MigrateDataWindow } from './components/MigrateDataWindow'; +import { useDragDropImport } from './hooks/useDragDropImport'; import { Toast } from '@/components/Toast'; import Spinner from '@/components/Spinner'; import LibraryHeader from './components/LibraryHeader'; @@ -72,6 +72,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP syncProgress, updateBook, setLibrary, + getGroupName, + refreshGroups, checkOpenWithBooks, checkLastOpenBooks, setCheckOpenWithBooks, @@ -92,7 +94,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP [key: string]: number | null; }>({}); const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState(null); - const [isDragging, setIsDragging] = useState(false); const isInitiating = useRef(false); const demoBooks = useDemoBooks(); @@ -106,6 +107,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useOpenWithBooks(); const { pullLibrary, pushLibrary } = useBooksSync(); + const { isDragging } = useDragDropImport(); usePullToRefresh(containerRef, pullLibrary); useScreenWakeLock(settings.screenWakeLock); @@ -155,64 +157,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } }, [appService]); - const handleDropedFiles = async (files: File[] | string[]) => { - if (files.length === 0) return; - const supportedFiles = files.filter((file) => { - let fileExt; - if (typeof file === 'string') { - fileExt = file.split('.').pop()?.toLowerCase(); - } else { - fileExt = file.name.split('.').pop()?.toLowerCase(); - } - return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`); - }); - if (supportedFiles.length === 0) { - eventDispatcher.dispatch('toast', { - message: _('No supported files found. Supported formats: {{formats}}', { - formats: BOOK_ACCEPT_FORMATS, - }), - type: 'error', - }); - return; - } - - if (appService?.hasHaptics) { - impactFeedback('medium'); - } - - const selectedFiles = supportedFiles.map( - (file) => - ({ - file: typeof file === 'string' ? undefined : file, - path: typeof file === 'string' ? file : undefined, - }) as SelectedFile, - ); - await importBooks(selectedFiles); - }; - - const handleDragOver = (event: React.DragEvent | DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (event: React.DragEvent | DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(false); - }; - - const handleDrop = async (event: React.DragEvent | DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(false); - - if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) { - const files = Array.from(event.dataTransfer.files); - handleDropedFiles(files); - } - }; - const handleRefreshLibrary = useCallback(async () => { const appService = await envConfig.getAppService(); const settings = await appService.loadSettings(); @@ -235,48 +179,30 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP return; }, [appService, handleRefreshLibrary]); - useEffect(() => { - const libraryPage = document.querySelector('.library-page'); - if (!appService?.isMobile) { - libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener); - libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener); - libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener); - } - - if (isTauriAppPlatform()) { - const unlisten = getCurrentWebview().onDragDropEvent((event) => { - if (event.payload.type === 'over') { - setIsDragging(true); - } else if (event.payload.type === 'drop') { - setIsDragging(false); - handleDropedFiles(event.payload.paths); - } else { - setIsDragging(false); - } - }); - return () => { - unlisten.then((fn) => fn()); - }; - } - - return () => { - if (!appService?.isMobile) { - libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener); - libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener); - libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener); - } - }; + const handleImportBookFiles = useCallback(async (event: CustomEvent) => { + const selectedFiles: SelectedFile[] = event.detail.files; + const groupId: string = event.detail.groupId || ''; + if (selectedFiles.length === 0) return; + await importBooks(selectedFiles, groupId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageRef.current]); + }, []); useEffect(() => { + eventDispatcher.on('import-book-files', handleImportBookFiles); + return () => { + eventDispatcher.off('import-book-files', handleImportBookFiles); + }; + }, [handleImportBookFiles]); + + useEffect(() => { + refreshGroups(); if (!libraryBooks.some((book) => !book.deletedAt)) { handleSetSelectMode(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [libraryBooks]); - const processOpenWithFiles = React.useCallback( + const processOpenWithFiles = useCallback( async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => { const settings = await appService.loadSettings(); const bookIds: string[] = []; @@ -415,7 +341,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP // eslint-disable-next-line react-hooks/exhaustive-deps }, [demoBooks, libraryLoaded]); - const importBooks = async (files: SelectedFile[]) => { + const importBooks = async (files: SelectedFile[], groupId?: string) => { setLoading(true); const { library } = useLibraryStore.getState(); const failedImports: Array<{ filename: string; errorMessage: string }> = []; @@ -433,6 +359,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP if (!file) return; try { const book = await appService?.importBook(file, library); + if (book && groupId) { + book.groupId = groupId; + book.groupName = getGroupName(groupId); + await updateBook(envConfig, book); + } if (user && book && !book.uploadedAt && settings.autoUpload) { console.log('Uploading book:', book.title); handleBookUpload(book, false); @@ -634,7 +565,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP console.log('Importing books...'); selectFiles({ type: 'books', multiple: true }).then((result) => { if (result.files.length === 0 || result.error) return; - importBooks(result.files); + const groupId = searchParams?.get('group') || ''; + importBooks(result.files, groupId); }); }; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 014df3c0..e896e8b4 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -408,7 +408,7 @@ export abstract class BaseAppService implements AppService { await f.close(); } - return book; + return existingBook || book; } catch (error) { throw error; } diff --git a/apps/readest-app/src/store/libraryStore.ts b/apps/readest-app/src/store/libraryStore.ts index 4612f263..12ad698d 100644 --- a/apps/readest-app/src/store/libraryStore.ts +++ b/apps/readest-app/src/store/libraryStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; -import { Book, BooksGroup } from '@/types/book'; +import { Book, BookGroupType, BooksGroup } from '@/types/book'; import { EnvConfigType, isTauriAppPlatform } from '@/services/environment'; +import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; +import { md5Fingerprint } from '@/utils/md5'; interface LibraryState { library: Book[]; // might contain deleted books @@ -10,6 +12,7 @@ interface LibraryState { checkLastOpenBooks: boolean; currentBookshelf: (Book | BooksGroup)[]; selectedBooks: Set; // hashes for books, ids for groups + groups: Record; setIsSyncing: (syncing: boolean) => void; setSyncProgress: (progress: number) => void; setSelectedBooks: (ids: string[]) => void; @@ -21,6 +24,11 @@ interface LibraryState { setLibrary: (books: Book[]) => void; updateBook: (envConfig: EnvConfigType, book: Book) => void; setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => void; + + refreshGroups: () => void; + addGroup: (name: string) => BookGroupType; + getGroups: () => BookGroupType[]; + getGroupName: (id: string) => string | undefined; } export const useLibraryStore = create((set, get) => ({ @@ -29,6 +37,7 @@ export const useLibraryStore = create((set, get) => ({ syncProgress: 0, currentBookshelf: [], selectedBooks: new Set(), + groups: {}, checkOpenWithBooks: isTauriAppPlatform(), checkLastOpenBooks: isTauriAppPlatform(), setIsSyncing: (syncing: boolean) => set({ isSyncing: syncing }), @@ -67,4 +76,44 @@ export const useLibraryStore = create((set, get) => ({ return { selectedBooks: newSelection }; }); }, + refreshGroups: () => { + const { library } = get(); + const groups: Record = {}; + + library.forEach((book) => { + if ( + book.groupId && + book.groupName && + book.groupId !== BOOK_UNGROUPED_ID && + book.groupName !== BOOK_UNGROUPED_NAME && + !book.deletedAt + ) { + groups[book.groupId] = book.groupName; + } + }); + + set({ groups }); + }, + addGroup: (name: string) => { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error('Group name cannot be empty'); + } + + const id = md5Fingerprint(trimmedName); + const { groups } = get(); + + set({ groups: { ...groups, [id]: trimmedName } }); + + return { id, name: trimmedName }; + }, + getGroups: () => { + const { groups } = get(); + return Object.entries(groups) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, + getGroupName: (id: string) => { + return get().groups[id]; + }, }));