feat(import): import files directly into the current book group (#2393)
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ export abstract class BaseAppService implements AppService {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
return book;
|
||||
return existingBook || book;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user