feat(import): import files directly into the current book group (#2393)

This commit is contained in:
Huang Xin
2025-11-03 12:47:26 +08:00
committed by GitHub
parent 637a813732
commit d00f1e0def
6 changed files with 224 additions and 152 deletions
@@ -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;
@@ -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<GroupingModalProps> = ({
}) => {
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<BookGroupType | null>(null);
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
const editorRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLInputElement>(null);
const iconSize = useResponsiveSize(16);
const isSelectedBooksHasGroup =
@@ -45,6 +43,20 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
.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<GroupingModalProps> = ({
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<GroupingModalProps> = ({
}
}, [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 (
<div className='fixed inset-0 flex items-center justify-center'>
@@ -183,7 +179,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
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()}
>
@@ -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<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | 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 };
};
+27 -95
View File
@@ -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<string[] | null>(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<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | 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);
});
};
+1 -1
View File
@@ -408,7 +408,7 @@ export abstract class BaseAppService implements AppService {
await f.close();
}
return book;
return existingBook || book;
} catch (error) {
throw error;
}
+50 -1
View File
@@ -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<string>; // hashes for books, ids for groups
groups: Record<string, string>;
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<LibraryState>((set, get) => ({
@@ -29,6 +37,7 @@ export const useLibraryStore = create<LibraryState>((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<LibraryState>((set, get) => ({
return { selectedBooks: newSelection };
});
},
refreshGroups: () => {
const { library } = get();
const groups: Record<string, string> = {};
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];
},
}));