From 5dc25284554d1814f922eee70677bdf54225f0bd Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 6 May 2026 01:42:16 +0800 Subject: [PATCH] fix(library): support dropping directories to import books (#4068) Drag-and-drop now classifies dropped items into files vs directories. Real files keep the existing import flow; dropped directories reuse handleImportBooksFromDirectory via a new import-book-directory event, matching the "From Directory" menu behavior instead of failing with "No supported files found". Co-authored-by: Claude Opus 4.7 (1M context) --- .../app/library/hooks/useDragDropImport.ts | 55 ++++++++++++------- apps/readest-app/src/app/library/page.tsx | 31 +++++++---- apps/readest-app/src/services/appService.ts | 9 +++ apps/readest-app/src/types/system.ts | 1 + 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/apps/readest-app/src/app/library/hooks/useDragDropImport.ts b/apps/readest-app/src/app/library/hooks/useDragDropImport.ts index 4e6f159c..01af5882 100644 --- a/apps/readest-app/src/app/library/hooks/useDragDropImport.ts +++ b/apps/readest-app/src/app/library/hooks/useDragDropImport.ts @@ -6,9 +6,14 @@ 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 { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants'; import { useSearchParams } from 'next/navigation'; +const hasSupportedBookExt = (name: string) => { + const ext = name.split('.').pop()?.toLowerCase(); + return ext ? SUPPORTED_BOOK_EXTS.includes(ext) : false; +}; + export const useDragDropImport = () => { const _ = useTranslation(); const searchParams = useSearchParams(); @@ -17,18 +22,27 @@ export const useDragDropImport = () => { 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(); + const handleDroppedFiles = async (droppedItems: File[] | string[]) => { + if (droppedItems.length === 0 || !appService) return; + + const fileItems: (File | string)[] = []; + const directoryPaths: string[] = []; + for (const item of droppedItems) { + if (typeof item === 'string' && (await appService.isDirectory(item, 'None'))) { + directoryPaths.push(item); } else { - fileExt = file.name.split('.').pop()?.toLowerCase(); + fileItems.push(item); } - return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`); - }); - if (supportedFiles.length === 0) { + } + + const fileSelections: SelectedFile[] = fileItems + .filter((item) => hasSupportedBookExt(typeof item === 'string' ? item : item.name)) + .map((item) => ({ + file: typeof item === 'string' ? undefined : item, + path: typeof item === 'string' ? item : undefined, + })); + + if (fileSelections.length === 0 && directoryPaths.length === 0) { eventDispatcher.dispatch('toast', { message: _('No supported files found. Supported formats: {{formats}}', { formats: BOOK_ACCEPT_FORMATS, @@ -38,18 +52,19 @@ export const useDragDropImport = () => { return; } - if (appService?.hasHaptics) { + 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 }); + if (fileSelections.length > 0) { + eventDispatcher.dispatch('import-book-files', { + files: fileSelections, + groupId: group, + }); + } + for (const dir of directoryPaths) { + eventDispatcher.dispatch('import-book-directory', { path: dir }); + } }; const handleDragOver = (event: React.DragEvent | DragEvent) => { diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 5f192301..b643ff08 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -333,12 +333,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleImportBookDirectory = useCallback(async (event: CustomEvent) => { + const dirPath: string | undefined = event.detail?.path; + if (!dirPath) return; + await handleImportBooksFromDirectory(dirPath); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { eventDispatcher.on('import-book-files', handleImportBookFiles); + eventDispatcher.on('import-book-directory', handleImportBookDirectory); return () => { eventDispatcher.off('import-book-files', handleImportBookFiles); + eventDispatcher.off('import-book-directory', handleImportBookDirectory); }; - }, [handleImportBookFiles]); + }, [handleImportBookFiles, handleImportBookDirectory]); useEffect(() => { if (!libraryBooks.some((book) => !book.deletedAt)) { @@ -802,19 +811,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }); }; - const handleImportBooksFromDirectory = async () => { + const handleImportBooksFromDirectory = async (dirPath?: string) => { if (!appService || !isTauriAppPlatform()) return; setIsSelectMode(false); console.log('Importing books from directory...'); - let importDirectory: string | undefined = ''; - if (appService.isAndroidApp) { - if (!(await requestStoragePermission())) return; - const response = await selectDirectory(); - importDirectory = response.path; - } else { - const selectedDir = await appService.selectDirectory?.('read'); - importDirectory = selectedDir; + let importDirectory: string | undefined = dirPath; + if (!importDirectory) { + if (appService.isAndroidApp) { + if (!(await requestStoragePermission())) return; + const response = await selectDirectory(); + importDirectory = response.path; + } else { + const selectedDir = await appService.selectDirectory?.('read'); + importDirectory = selectedDir; + } } if (!importDirectory) { console.log('No directory selected'); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 3ab0bff7..9dcfd8eb 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -158,6 +158,15 @@ export abstract class BaseAppService implements AppService { return await this.fs.exists(path, base); } + async isDirectory(path: string, base: BaseDir): Promise { + try { + const info = await this.fs.stats(path, base); + return info.isDirectory; + } catch { + return false; + } + } + async getImageURL(path: string): Promise { return await this.fs.getImageURL(path); } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 34b66804..d56e07b4 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -109,6 +109,7 @@ export interface AppService { deleteFile(path: string, base: BaseDir): Promise; deleteDir(path: string, base: BaseDir, recursive?: boolean): Promise; exists(path: string, base: BaseDir): Promise; + isDirectory(path: string, base: BaseDir): Promise; getImageURL(path: string): Promise; setCustomRootDir(customRootDir: string): Promise;