forked from akai/readest
72233e1c6a
* docs(sync): design spec for syncing reading status (#4634) Field-level LWW for reading_status (dedicated reading_status_updated_at), a new 'abandoned' status in the Readest UI, and a koplugin bridge to KOReader's native summary.status (whole-library apply + capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): implementation plan for syncing reading status (#4634) Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW (reading_status_updated_at on server upsert + client pull-merge), B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge to KOReader summary.status (mapping + reconcile + whole-library apply/capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): add reading_status_updated_at for field-level status LWW (#4634) * feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): stamp status timestamp on explicit library status edits * feat(sync): resolve reading status by its own timestamp in client pull-merge * feat(sync): resolve reading status by its own timestamp in server upsert (#4634) * fix(sync): tighten reading-status merge typing + strengthen test (A5 review) Replace as-unknown-as double-casts at read sites with typed locals (clientBook/serverBook); retain a single as-unknown-as only at the server-wins construction site where the static type is too narrow. Strengthen test 3 to assert both fields with toEqual. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(library): render the 'On hold' (abandoned) status badge * feat(library): add 'Mark as On hold' actions + i18n for abandoned status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader * feat(koplugin): persist + sync reading_status_updated_at in LibraryStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634) * test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634) KOReader auto-sets summary.status='reading' on first open, and legacy Readest statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a finished book downgrade it. Restrict sync to deliberate statuses (finished/ complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'. On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative, then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns write_ko/write_store flags; statussync captures now_ms once and equalizes both sides (convergent, idempotent, resumable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(koplugin): cover remaining first-sync graph cells + document sort effect (review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
8.8 KiB
TypeScript
277 lines
8.8 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 statusChanged = readingStatus !== book.readingStatus;
|
|
const updatedBook: Book = {
|
|
...book,
|
|
progress,
|
|
readingStatus,
|
|
readingStatusUpdatedAt: statusChanged ? Date.now() : book.readingStatusUpdatedAt,
|
|
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;
|
|
},
|
|
}));
|