Files
readest/apps/readest-app/src/store/libraryStore.ts
T
Huang Xin 72233e1c6a feat: sync reading status across devices and with KOReader (#4634) (#4656)
* 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>
2026-06-19 05:00:40 +02:00

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;
},
}));