forked from akai/readest
ca8f0fe9f6
* feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser * refactor(opds): remove custom parser, use updated foliate-js dependency * fix(opds): resolve PSE auth at fetch time, drop credentials from book.url Previously the streaming `pse://` virtual file baked the proxy URL with the basic auth header into `book.url`, which (a) failed on desktop where no proxy is used because the auth header was never applied to the page fetch, and (b) leaked the credential to the sync server because transient books still get pushed on first sync. Now the `pse://` payload stores only the upstream OPDS template URL plus the catalog id. A new `createPseStreamPageLoader` looks up the catalog from settings on each open, probes auth once (cached for the session), and applies the auth header via `tauriFetch` on desktop or via the proxy URL on web. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
496 lines
16 KiB
TypeScript
496 lines
16 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;
|
|
/* 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;
|
|
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,
|
|
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');
|
|
}
|
|
}
|
|
// 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,
|
|
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,
|
|
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,
|
|
},
|
|
},
|
|
})),
|
|
|
|
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()}`,
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
},
|
|
}));
|