feat(sync): add readest koplugin for progress sync, also implements #1729 (#1785)

This commit is contained in:
Huang Xin
2025-08-13 00:24:47 +08:00
committed by GitHub
parent 3698e6ca28
commit 0dfa8e96f5
11 changed files with 291 additions and 175 deletions
@@ -108,6 +108,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
[settings.koreaderSyncUserkey],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
debounce((newDeviceName: string) => {
const newSettings = { ...settings, koreaderSyncDeviceName: newDeviceName };
@@ -7,14 +7,12 @@ import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
import { Book, BookFormat } from '@/types/book';
import { Book, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
const PAGINATED_FORMATS: Set<BookFormat> = new Set(['PDF', 'CBZ']);
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
export interface SyncDetails {
@@ -62,7 +60,7 @@ export const useKOSync = (bookKey: string) => {
let progressStr: string;
let percentage: number;
if (PAGINATED_FORMATS.has(currentBook.format)) {
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const page = (currentProgress.section?.current ?? 0) + 1;
const totalPages = currentProgress.section?.total ?? 0;
progressStr = page.toString();
@@ -223,12 +221,12 @@ export const useKOSync = (bookKey: string) => {
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
const localIdentifier = PAGINATED_FORMATS.has(book.format)
const localIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = PAGINATED_FORMATS.has(book.format)
const remoteIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? (parseInt(remote.progress, 10) - 1).toString()
: remote.progress.startsWith('epubcfi')
? remote.progress
@@ -265,7 +263,7 @@ export const useKOSync = (bookKey: string) => {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (PAGINATED_FORMATS.has(book.format)) {
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress, 10);
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
} else {
@@ -312,7 +310,7 @@ export const useKOSync = (bookKey: string) => {
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (PAGINATED_FORMATS.has(book.format)) {
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = progress.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
@@ -427,7 +425,7 @@ export const useKOSync = (bookKey: string) => {
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
if (PAGINATED_FORMATS.has(currentBook.format)) {
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const localTotalPages = getProgress(bookKey)?.section?.total ?? 0;
const remotePage = parseInt(remote.progress, 10);
const remotePercentage = remote.percentage || 0;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookConfig } from '@/types/book';
import { BookConfig, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -11,15 +11,15 @@ import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getConfig, setConfig, getBookData } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const { settings } = useSettingsStore();
const { syncedConfigs, syncConfigs } = useSync(bookKey);
const { user } = useAuth();
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
@@ -29,24 +29,38 @@ export const useProgressSync = (bookKey: string) => {
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
const bookHash = bookKey.split('-')[0]!;
const newConfig = { bookHash, ...config };
const newConfig = { ...config, bookHash };
const compressedConfig = JSON.parse(
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
);
delete compressedConfig.booknotes;
syncConfigs([compressedConfig], bookHash, 'push');
};
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
const syncConfig = async () => {
if (!configPulled.current) {
pullConfig(bookKey);
} else {
const config = getConfig(bookKey);
if (config && config.progress && config.progress[0] > 0) {
const view = getView(bookKey);
const book = getBookData(bookKey)?.book;
if (config && view && book && config.progress && config.progress[0] > 0) {
try {
const content = view.renderer.getContents()[0];
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
config.xpointer = xpointerResult.xpointer;
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
}
pushConfig(bookKey, config);
}
}
@@ -91,28 +105,51 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
// Pull: proccess the pulled progress
useEffect(() => {
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;
const syncedCFI = syncedConfig.location;
setConfig(bookKey, syncedConfig);
if (syncedCFI && configCFI) {
if (CFI.compare(configCFI, syncedCFI) < 0) {
if (view) {
view.goTo(syncedCFI);
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Reading Progress Synced'),
});
}
const applyRemoteProgress = useCallback(async () => {
if (!syncedConfigs || syncedConfigs.length === 0) return;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;
let remoteCFILocation = syncedConfig.location;
const xPointer = syncedConfig.xpointer;
const bookData = getBookData(bookKey);
const view = getView(bookKey);
if (xPointer && view && bookData && bookData.bookDoc) {
const content = view.renderer.getContents()[0];
const candidateCFI = await getCFIFromXPointer(
xPointer,
content?.doc,
content?.index,
bookData.bookDoc,
);
if (CFI.compare(remoteCFILocation, candidateCFI) < 0) {
remoteCFILocation = candidateCFI;
}
}
setConfig(bookKey, syncedConfig);
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
if (view) {
view.goTo(remoteCFILocation);
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Reading Progress Synced'),
});
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedConfigs]);
}, [syncedConfigs, config?.location]);
// Pull: proccess the pulled progress
useEffect(() => {
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
applyRemoteProgress().catch((error) => {
console.error('Failed to apply remote progress', error);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyRemoteProgress]);
};
+4 -1
View File
@@ -5,6 +5,8 @@ export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
export const FIXED_LAYOUT_FORMATS: Set<BookFormat> = new Set(['PDF', 'CBZ']);
export interface Book {
// if Book is a remote book we just lazy load the book content via url
url?: string;
@@ -221,7 +223,8 @@ export interface BookSearchResult {
export interface BookConfig {
bookHash?: string;
progress?: [number, number]; // [current pagenum, total pagenum], 1-based page number
location?: string;
location?: string; // CFI of the current location
xpointer?: string; // XPointer of the current location (for Koreader interoperability)
booknotes?: BookNote[];
searchConfig?: Partial<BookSearchConfig>;
viewSettings?: Partial<ViewSettings>;
+1
View File
@@ -21,6 +21,7 @@ export interface DBBookConfig {
user_id: string;
book_hash: string;
location?: string;
xpointer?: string;
progress?: string;
search_config?: string;
view_settings?: string;
+5 -2
View File
@@ -11,13 +11,14 @@ import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
import { sanitizeString } from './sanitize';
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } =
const { bookHash, progress, location, xpointer, searchConfig, viewSettings, updatedAt } =
bookConfig as BookConfig;
return {
user_id: userId,
book_hash: bookHash!,
location: location,
xpointer: xpointer,
progress: progress && JSON.stringify(progress),
search_config: searchConfig && JSON.stringify(searchConfig),
view_settings: viewSettings && JSON.stringify(viewSettings),
@@ -26,10 +27,12 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
};
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig;
const { book_hash, progress, location, xpointer, search_config, view_settings, updated_at } =
dbBookConfig;
return {
bookHash: book_hash,
location,
xpointer,
progress: progress && JSON.parse(progress),
searchConfig: search_config && JSON.parse(search_config),
viewSettings: view_settings && JSON.parse(view_settings),
+23 -3
View File
@@ -502,13 +502,13 @@ export class XCFI {
export const getCFIFromXPointer = async (
xpointer: string,
doc: Document,
index: number,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
) => {
const xSpineIndex = XCFI.extractSpineIndex(xpointer);
let converter: XCFI;
if (index === xSpineIndex) {
if (index === xSpineIndex && doc) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
@@ -519,3 +519,23 @@ export const getCFIFromXPointer = async (
const cfi = converter.xPointerToCFI(xpointer);
return cfi;
};
export const getXPointerFromCFI = async (
cfi: string,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
): Promise<XPointer> => {
const xSpineIndex = XCFI.extractSpineIndex(cfi);
let converter: XCFI;
if (index === xSpineIndex && doc) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!doc) throw new Error('Failed to load document for CFI conversion.');
converter = new XCFI(doc, xSpineIndex || 0);
}
const xpointer = converter.cfiToXPointer(cfi);
return xpointer;
};