Files
readest/apps/readest-app/src/store/readerStore.ts
T
Huang Xin 5e366018df fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n

Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").

CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
  description / subject / identifier / published / series fields
  beyond the prior name+position pair. Series Count populates the
  canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
  from `belongsTo.series.total` in parallel to the existing
  series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
  `pagesLeft = 1` for fixed-layout books and compute it from
  `section.total - section.current`. FooterBar uses
  `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
  (correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
  fixed-layout titles (no chapter structure) and keep
  "in chapter" for reflowable books.

WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
  prop) instead of `_(...)`. The i18next-scanner only looks for
  `_`, so ~53 strings were unreachable and shipped in English to
  every locale. Switched both components to call
  `useTranslation()` themselves; helpers that aren't React FCs
  take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
  (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
  `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
  reserved for the dev console. New `formatConnectError` and
  `formatSyncError` helpers in WebDAVForm translate via a switch
  where each branch is a literal `_('...')`. Same treatment for
  the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
  "Syncing {{n}} / {{total}}" with n=0 at startup so the digit
  formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
  ternary; rewrote as plural-aware key.

i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
  extensions (Next.js route folder `runtime-config.js/`, Playwright
  screenshot folder `*.test.tsx/`) and crashed with EISDIR.
  Resolved by expanding globs via `fs.globSync` and filtering to
  files only before handing to the scanner.

TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
  `failed[0]!.title` inside `_(..., options)` arguments. Replaced
  with `e instanceof Error ? e.message : String(e)` and
  `failed[0]?.title ?? ''` — also runtime-safer.

User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
  WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
  replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.

Locale translations:
- ~2400 translations applied across all 33 locales for the keys
  that were either newly extractable, freshly worded, or
  pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
  remain after the run.

Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
  runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(test): fix vitest invocation, run with 4 workers

`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:

    dotenv -e .env -e .env.test.local -- vitest -- --watch=false

The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).

Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:59:29 +02:00

519 lines
17 KiB
TypeScript

import { create } from 'zustand';
import {
BookContent,
BookConfig,
PageInfo,
BookProgress,
ViewSettings,
TimeInfo,
FIXED_LAYOUT_FORMATS,
} from '@/types/book';
import { Insets } from '@/types/misc';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { DocumentLoader, TOCItem } from '@/libs/document';
import {
isPseStreamFileName,
openPseStreamBook,
parsePseStreamFileName,
} from '@/services/opds/pseStream';
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav';
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
import { getBaseFilename } from '@/utils/path';
import { SUPPORTED_LANGNAMES } from '@/services/constants';
import { useSettingsStore } from './settingsStore';
import { BookData, useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { uniqueId } from '@/utils/misc';
interface ViewState {
/* Unique key for each book view */
key: string;
view: FoliateView | null;
viewerKey: string;
isPrimary: boolean;
loading: boolean;
inited: boolean;
error: string | null;
progress: BookProgress | null;
ribbonVisible: boolean;
ttsEnabled: boolean;
syncing: boolean;
gridInsets: Insets | null;
/* True while the reader is showing a position requested by an external
deep link (e.g. ?cfi=...) that the user hasn't yet confirmed by reading.
Progress writers (auto-save, cloud sync, kosync) skip while this is true
so the user's actual last-read position isn't overwritten by a preview.
Cleared on the first user-initiated relocate (page turn / scroll). */
previewMode: boolean;
/* View settings for the view:
generally view settings have a hierarchy of global settings < book settings < view settings
view settings for primary view are saved to book config which is persisted to config file
omitting settings that are not changed from global settings */
viewSettings: ViewSettings | null;
}
interface ReaderStore {
viewStates: { [key: string]: ViewState };
bookKeys: string[];
hoveredBookKey: string | null;
setBookKeys: (keys: string[]) => void;
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
setTTSEnabled: (key: string, enabled: boolean) => void;
setIsLoading: (key: string, loading: boolean) => void;
setIsSyncing: (key: string, syncing: boolean) => void;
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) => void;
getProgress: (key: string) => BookProgress | null;
setView: (key: string, view: FoliateView) => void;
getView: (key: string | null) => FoliateView | null;
getViews: () => FoliateView[];
getViewsById: (id: string) => FoliateView[];
setViewSettings: (key: string, viewSettings: ViewSettings) => void;
getViewSettings: (key: string) => ViewSettings | null;
initViewState: (
envConfig: EnvConfigType,
id: string,
key: string,
isPrimary?: boolean,
reload?: boolean,
) => Promise<void>;
clearViewState: (key: string) => void;
getViewState: (key: string) => ViewState | null;
getGridInsets: (key: string) => Insets | null;
setGridInsets: (key: string, insets: Insets | null) => void;
setViewInited: (key: string, inited: boolean) => void;
setPreviewMode: (key: string, previewMode: boolean) => void;
recreateViewer: (envConfig: EnvConfigType, key: string) => void;
}
export const useReaderStore = create<ReaderStore>((set, get) => ({
viewStates: {},
bookKeys: [],
hoveredBookKey: null,
setBookKeys: (keys: string[]) => set({ bookKeys: keys }),
setHoveredBookKey: (key: string | null) => set({ hoveredBookKey: key }),
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: { ...state.viewStates[key]!, view },
},
})),
getViews: () => Object.values(get().viewStates).map((state) => state.view!),
getViewsById: (id: string) => {
const { viewStates } = get();
return Object.values(viewStates)
.filter((state) => state.key && state.key.startsWith(id))
.map((state) => state.view!);
},
clearViewState: (key: string) => {
set((state) => {
const viewStates = { ...state.viewStates };
delete viewStates[key];
return { viewStates };
});
},
getViewState: (key: string) => get().viewStates[key] || null,
initViewState: async (
envConfig: EnvConfigType,
id: string,
key: string,
isPrimary = true,
reload = false,
) => {
const booksData = useBookDataStore.getState().booksData;
const bookData = booksData[id];
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: true,
inited: false,
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: null,
},
},
}));
try {
const appService = await envConfig.getAppService();
const { settings } = useSettingsStore.getState();
const { getBookByHash, library } = useLibraryStore.getState();
const book = getBookByHash(id);
if (!book) {
console.error(
`Book ${id} not found in library (size=${library.length}); likely the in-memory entry was dropped by a library reload.`,
);
throw new Error('Book not found');
}
const isPseStream = !!book.url && isPseStreamFileName(book.url);
let bookDoc = bookData?.bookDoc;
let file: File | null = bookData?.file ?? null;
if (!bookDoc || (!isPseStream && !file) || reload) {
console.log('Loading book', key);
if (isPseStream) {
const data = parsePseStreamFileName(book.url!);
const doc = await openPseStreamBook(data);
bookDoc = doc.book;
file = null;
} else {
const content = (await appService.loadBookContent(book)) as BookContent;
file = content.file;
const doc = await new DocumentLoader(file).open();
bookDoc = doc.book;
}
}
const config = await appService.loadBookConfig(book, settings);
// Import annotations from third-party readers on first open
if (bookDoc.metadata.identifier) {
const { getAnnotationProviders } = await import('@/services/annotation');
for (const provider of getAnnotationProviders()) {
if (provider.isAvailable(appService)) {
const merged = await provider.importAnnotations(
appService,
bookDoc.metadata.identifier,
config,
);
if (merged !== config) {
Object.assign(config, merged);
await appService.saveBookConfig(book, config, settings);
}
}
}
}
// Filter out invalid booknotes
config.booknotes = config.booknotes?.filter((booknote) => booknote.cfi) ?? [];
// Load cached book navigation (TOC + section fragments) or compute and persist.
if (book.format === 'EPUB' && bookDoc.rendition?.layout !== 'pre-paginated') {
const cachedNav = await appService.loadBookNav(book);
if (cachedNav?.version === BOOK_NAV_VERSION && process.env.NODE_ENV === 'production') {
hydrateBookNav(bookDoc, cachedNav);
} else {
const freshNav = await computeBookNav(bookDoc);
hydrateBookNav(bookDoc, freshNav);
try {
await appService.saveBookNav(book, freshNav);
} catch (e) {
console.warn('Failed to persist book nav cache:', e);
}
}
}
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
config.viewSettings?.convertChineseVariant ?? 'none',
);
if (!bookDoc.metadata.title && file) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
// Correct language codes mistakenly set with language names
if (typeof bookDoc.metadata?.language === 'string') {
if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) {
bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!;
}
}
// Set the book's language for formerly imported books, newly imported books have this field set
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
// Update series info from metadata if available and not already set on the book
if (bookDoc.metadata.belongsTo?.series) {
const belongsTo = bookDoc.metadata.belongsTo.series;
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
if (series) {
book.metadata.series = book.metadata.series ?? formatTitle(series.name);
book.metadata.seriesIndex =
book.metadata.seriesIndex ?? parseFloat(series.position || '0');
book.metadata.seriesTotal =
book.metadata.seriesTotal ?? (series.total ? parseInt(series.total, 10) : undefined);
}
}
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
book.metaHash = getMetadataHash(bookDoc.metadata);
const isFixedLayout =
bookDoc.rendition?.layout === 'pre-paginated' || FIXED_LAYOUT_FORMATS.has(book.format);
const newBookData: BookData = { id, book, file, config, bookDoc, isFixedLayout };
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: newBookData,
},
}));
const configViewSettings = config.viewSettings!;
const globalViewSettings = settings.globalViewSettings;
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key],
key,
view: null,
viewerKey: `${key}-${uniqueId()}`,
isPrimary,
loading: false,
inited: false,
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: { ...globalViewSettings, ...configViewSettings },
},
},
}));
} catch (error) {
console.error(error);
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key],
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: false,
inited: false,
error: 'Failed to load book.',
progress: null,
ribbonVisible: false,
ttsEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: null,
},
},
}));
throw error;
}
},
getViewSettings: (key: string) => get().viewStates[key]?.viewSettings || null,
setViewSettings: (key: string, viewSettings: ViewSettings) => {
if (!key) return;
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = get().viewStates[key];
if (!viewState || !bookData) return;
if (viewState.isPrimary) {
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: {
...bookData,
config: {
...bookData.config,
updatedAt: Date.now(),
viewSettings,
},
},
},
}));
}
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
viewSettings,
},
},
}));
},
getProgress: (key: string) => get().viewStates[key]?.progress || null,
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) =>
set((state) => {
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
// Lightweight library update — O(1) lookup, no array copy, no refreshGroups
const { getBookByHash, updateBookProgress } = useLibraryStore.getState();
const existingBook = getBookByHash(id);
if (existingBook) {
let newReadingStatus = existingBook.readingStatus;
if (existingBook.readingStatus === 'unread') {
newReadingStatus = undefined;
}
if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') {
newReadingStatus = 'finished';
}
updateBookProgress(id, progress, newReadingStatus);
}
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
progress,
location,
} as BookConfig;
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: {
...bookData,
config: viewState.isPrimary ? newConfig : oldConfig,
},
},
}));
return {
viewStates: {
...state.viewStates,
[key]: {
...viewState,
progress: {
...viewState.progress,
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
section,
pageinfo,
timeinfo,
index: section.current,
range,
page: pageInfo.current + 1,
} as BookProgress,
},
},
};
}),
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ribbonVisible: visible,
},
},
})),
setTTSEnabled: (key: string, enabled: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ttsEnabled: enabled,
},
},
})),
setIsLoading: (key: string, loading: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
loading,
},
},
})),
setIsSyncing: (key: string, syncing: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
syncing,
},
},
})),
getGridInsets: (key: string) =>
get().viewStates[key]?.gridInsets || { top: 0, right: 0, bottom: 0, left: 0 },
setGridInsets: (key: string, insets: Insets | null) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
gridInsets: insets,
},
},
})),
setViewInited: (key: string, inited: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
inited,
},
},
})),
setPreviewMode: (key: string, previewMode: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
previewMode,
},
},
})),
recreateViewer: (envConfig: EnvConfigType, key: string) => {
const id = key.split('-')[0]!;
get()
.initViewState(envConfig, id, key, true, true)
.then(() => {
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
viewerKey: `${key}-${uniqueId()}`,
},
},
}));
});
},
}));