forked from akai/readest
ded64159b6
* perf(send): dynamic-import the conversion fallback so /library stays lean
conversionWorker.ts value-imported convertToEpub for the no-Worker
fallback path. That pulled mammoth, @mozilla/readability, DOMPurify and
@zip.js/zip.js into the main bundle — eagerly loaded on /library via
useInboxDrainer's static import of conversionWorker.
Switch the fallback to `await import('./convertToEpub')`. The worker
entry still value-imports convertToEpub for its own chunk; the
main-thread fallback only loads the heavy deps when Workers are actually
unavailable or fail.
Measured on the production web build:
- before: /library eagerly loads the 634KB conversion chunk
- after: the 634KB chunk + its two ~627KB duplicates are all lazy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(send): never clobber the library when /send writes before it has loaded
The /send page (and the inbox drainer when it races the library page's
load) called `useLibraryStore.updateBooks(envConfig, [book])` while the
store still held the empty initial library — `libraryLoaded: false`. The
merge ran against `[]`, so `saveLibraryBooks` persisted just the new
book as the *entire* library and sync pushed the clobbered copy to every
device.
Two-layer fix:
1. Harden `updateBooks`: if `libraryLoaded` is false, load the real
library from disk first, then merge — `updateBooks` is now self-
protecting against any future caller that forgets the load step.
2. Gate `useInboxDrainer` on `libraryLoaded`. The hook now subscribes to
the flag and starts draining the moment the library finishes loading,
instead of running the first pass against an empty in-memory copy.
Adds a regression test that fails without the store change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
8.7 KiB
TypeScript
275 lines
8.7 KiB
TypeScript
import { create } from 'zustand';
|
|
import { Book, BookGroupType, BooksGroup, ReadingStatus } from '@/types/book';
|
|
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
|
|
import { BOOK_UNGROUPED_NAME } from '@/services/constants';
|
|
import { md5Fingerprint } from '@/utils/md5';
|
|
|
|
interface LibraryState {
|
|
library: Book[]; // might contain deleted books
|
|
libraryLoaded: boolean;
|
|
isSyncing: boolean;
|
|
syncProgress: number;
|
|
checkOpenWithBooks: boolean;
|
|
checkLastOpenBooks: boolean;
|
|
currentBookshelf: (Book | BooksGroup)[];
|
|
selectedBooks: Set<string>; // hashes for books, ids for groups
|
|
groups: Record<string, string>;
|
|
hashIndex: Map<string, number>; // hash -> array index for O(1) lookup
|
|
visibleLibrary: Book[];
|
|
setIsSyncing: (syncing: boolean) => void;
|
|
setSyncProgress: (progress: number) => void;
|
|
setSelectedBooks: (ids: string[]) => void;
|
|
getSelectedBooks: () => string[];
|
|
toggleSelectedBook: (id: string) => void;
|
|
getVisibleLibrary: () => Book[];
|
|
getBookByHash: (hash: string) => Book | undefined;
|
|
setCheckOpenWithBooks: (check: boolean) => void;
|
|
setCheckLastOpenBooks: (check: boolean) => void;
|
|
setLibrary: (books: Book[]) => void;
|
|
// The third parameter is required (no `?`) so a future caller cannot
|
|
// accidentally clear `readingStatus` by omitting it. Pass the desired final
|
|
// value explicitly: the existing `readingStatus`, `undefined` to clear, or
|
|
// a new status like 'finished'.
|
|
updateBookProgress: (
|
|
hash: string,
|
|
progress: [number, number],
|
|
readingStatus: ReadingStatus | undefined,
|
|
) => void;
|
|
updateBook: (envConfig: EnvConfigType, book: Book) => Promise<void>;
|
|
updateBooks: (
|
|
envConfig: EnvConfigType,
|
|
books: Book[],
|
|
options?: { skipSave?: boolean },
|
|
) => Promise<void>;
|
|
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => void;
|
|
refreshGroups: () => void;
|
|
rebuildHashIndex: () => void;
|
|
addGroup: (name: string) => BookGroupType;
|
|
getGroups: () => BookGroupType[];
|
|
getGroupId: (path: string) => string | undefined;
|
|
getGroupName: (id: string) => string | undefined;
|
|
getParentPath: (path: string) => string | undefined;
|
|
getGroupsByParent: (parentPath?: string) => BookGroupType[];
|
|
}
|
|
|
|
function buildHashIndex(books: Book[]): Map<string, number> {
|
|
const index = new Map<string, number>();
|
|
for (let i = 0; i < books.length; i++) {
|
|
index.set(books[i]!.hash, i);
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
|
library: [],
|
|
libraryLoaded: false,
|
|
isSyncing: false,
|
|
syncProgress: 0,
|
|
currentBookshelf: [],
|
|
selectedBooks: new Set(),
|
|
groups: {},
|
|
hashIndex: new Map(),
|
|
visibleLibrary: [],
|
|
checkOpenWithBooks: isTauriAppPlatform(),
|
|
checkLastOpenBooks: isTauriAppPlatform(),
|
|
|
|
setIsSyncing: (syncing: boolean) => set({ isSyncing: syncing }),
|
|
setSyncProgress: (progress: number) => set({ syncProgress: progress }),
|
|
getVisibleLibrary: () => get().visibleLibrary,
|
|
getBookByHash: (hash: string) => {
|
|
const { library, hashIndex } = get();
|
|
const idx = hashIndex.get(hash);
|
|
return idx !== undefined ? library[idx] : undefined;
|
|
},
|
|
|
|
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => {
|
|
set({ currentBookshelf: bookshelf });
|
|
},
|
|
|
|
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
|
|
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
|
|
setLibrary: (books) => {
|
|
set({
|
|
library: books,
|
|
libraryLoaded: true,
|
|
hashIndex: buildHashIndex(books),
|
|
visibleLibrary: books.filter((b) => !b.deletedAt),
|
|
});
|
|
get().refreshGroups();
|
|
},
|
|
|
|
// Immutable lightweight progress update — skips refreshGroups (which is the
|
|
// expensive O(n) MD5 path) but still creates new array references for
|
|
// `library` and `visibleLibrary` so Zustand subscribers re-render correctly
|
|
// and the visibleLibrary cache stays in sync.
|
|
updateBookProgress: (hash, progress, readingStatus) => {
|
|
const { library, hashIndex } = get();
|
|
const idx = hashIndex.get(hash);
|
|
if (idx === undefined) return;
|
|
const book = library[idx]!;
|
|
const updatedBook: Book = {
|
|
...book,
|
|
progress,
|
|
readingStatus,
|
|
updatedAt: Date.now(),
|
|
};
|
|
const newLibrary = library.slice();
|
|
newLibrary[idx] = updatedBook;
|
|
set({
|
|
library: newLibrary,
|
|
visibleLibrary: newLibrary.filter((b) => !b.deletedAt),
|
|
});
|
|
},
|
|
|
|
rebuildHashIndex: () => {
|
|
set({ hashIndex: buildHashIndex(get().library) });
|
|
},
|
|
|
|
updateBook: async (envConfig: EnvConfigType, book: Book) => {
|
|
const appService = await envConfig.getAppService();
|
|
const { library, hashIndex } = get();
|
|
const idx = hashIndex.get(book.hash);
|
|
// Build the new library immutably — never mutate the previous-state array.
|
|
const newLibrary =
|
|
idx !== undefined
|
|
? [...library.slice(0, idx), book, ...library.slice(idx + 1)]
|
|
: library.slice();
|
|
set({
|
|
library: newLibrary,
|
|
hashIndex: buildHashIndex(newLibrary),
|
|
visibleLibrary: newLibrary.filter((b) => !b.deletedAt),
|
|
});
|
|
await appService.saveLibraryBooks(newLibrary);
|
|
},
|
|
updateBooks: async (
|
|
envConfig: EnvConfigType,
|
|
books: Book[],
|
|
options?: { skipSave?: boolean },
|
|
) => {
|
|
if (!books?.length) return;
|
|
|
|
// Hardening: if a caller (e.g. /send, the inbox drainer) hits us before
|
|
// `setLibrary` has populated the store, merging against the empty
|
|
// in-memory array would persist `books` as the *entire* library and
|
|
// clobber whatever is on disk. Load the real library first.
|
|
let { library } = get();
|
|
const { libraryLoaded, refreshGroups } = get();
|
|
if (!libraryLoaded) {
|
|
const appService = await envConfig.getAppService();
|
|
library = await appService.loadLibraryBooks();
|
|
set({
|
|
library,
|
|
libraryLoaded: true,
|
|
hashIndex: buildHashIndex(library),
|
|
visibleLibrary: library.filter((b) => !b.deletedAt),
|
|
});
|
|
}
|
|
|
|
const newLibrary = Array.from(new Map([...library, ...books].map((b) => [b.hash, b])).values());
|
|
set({
|
|
library: newLibrary,
|
|
hashIndex: buildHashIndex(newLibrary),
|
|
visibleLibrary: newLibrary.filter((b) => !b.deletedAt),
|
|
});
|
|
refreshGroups();
|
|
|
|
if (!options?.skipSave) {
|
|
const appService = await envConfig.getAppService();
|
|
await appService.saveLibraryBooks(newLibrary);
|
|
}
|
|
},
|
|
|
|
setSelectedBooks: (ids: string[]) => {
|
|
set({ selectedBooks: new Set(ids) });
|
|
},
|
|
|
|
getSelectedBooks: () => {
|
|
return Array.from(get().selectedBooks);
|
|
},
|
|
|
|
toggleSelectedBook: (id: string) => {
|
|
set((state) => {
|
|
const newSelection = new Set(state.selectedBooks);
|
|
if (newSelection.has(id)) {
|
|
newSelection.delete(id);
|
|
} else {
|
|
newSelection.add(id);
|
|
}
|
|
return { selectedBooks: newSelection };
|
|
});
|
|
},
|
|
|
|
refreshGroups: () => {
|
|
const { library } = get();
|
|
const groups: Record<string, string> = {};
|
|
|
|
library.forEach((book) => {
|
|
if (book.groupName && book.groupName !== BOOK_UNGROUPED_NAME && !book.deletedAt) {
|
|
groups[md5Fingerprint(book.groupName)] = book.groupName;
|
|
let nextSlashIndex = book.groupName.indexOf('/', 0);
|
|
while (nextSlashIndex > 0) {
|
|
const groupName = book.groupName.substring(0, nextSlashIndex);
|
|
groups[md5Fingerprint(groupName)] = groupName;
|
|
nextSlashIndex = book.groupName.indexOf('/', nextSlashIndex + 1);
|
|
}
|
|
}
|
|
});
|
|
|
|
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));
|
|
},
|
|
|
|
getGroupId: (path: string) => {
|
|
const { groups } = get();
|
|
|
|
const directId = Object.entries(groups).find(([_, name]) => name === path)?.[0];
|
|
if (directId) {
|
|
return directId;
|
|
}
|
|
|
|
return md5Fingerprint(path);
|
|
},
|
|
|
|
getGroupName: (id: string) => {
|
|
return get().groups[id];
|
|
},
|
|
|
|
getParentPath: (path: string) => {
|
|
const lastSlashIndex = path.lastIndexOf('/');
|
|
if (lastSlashIndex === -1) return '';
|
|
return path.slice(0, lastSlashIndex);
|
|
},
|
|
|
|
getGroupsByParent: (parentPath?: string) => {
|
|
const { groups } = get();
|
|
const result: BookGroupType[] = [];
|
|
Object.entries(groups).forEach(([id, name]) => {
|
|
const groupParentPath = get().getParentPath(name);
|
|
if (groupParentPath === (parentPath || '')) {
|
|
result.push({ id, name });
|
|
}
|
|
});
|
|
return result;
|
|
},
|
|
}));
|