refactor(kosync): move kosync into reader sidebar menu and add manual pull/push trigger (#1912)

This commit is contained in:
Huang Xin
2025-08-27 13:32:00 +08:00
committed by GitHub
parent 3ea54e6725
commit 584f3511f8
32 changed files with 380 additions and 618 deletions
+203 -371
View File
@@ -1,13 +1,11 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
import { Book, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
@@ -30,386 +28,257 @@ export interface SyncDetails {
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const progress = getProgress(bookKey);
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
const [syncState, setSyncState] = useState<SyncState>('idle');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const hasPulledOnce = useRef(false);
const syncCompletedForKey = useRef<string | null>(null);
const lastPushedCfiRef = useRef<string | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
const progress = getProgress(bookKey);
useEffect(() => {
lastPushedCfiRef.current = null;
syncCompletedForKey.current = null;
setSyncState('idle');
}, [bookKey]);
if (!settings.kosync.username || !settings.kosync.userkey) {
setKOSyncClient(null);
return;
}
const client = new KOSyncClient(settings.kosync);
setKOSyncClient(client);
}, [settings]);
const mapProgressToServerFormat = useCallback(() => {
const currentProgress = getProgress(bookKey);
const currentBook = getBookData(bookKey)?.book;
if (!currentProgress || !currentBook) return null;
const generateKOProgress = useCallback(() => {
const progress = getProgress(bookKey);
const book = getBookData(bookKey)?.book;
if (!progress || !book) return null;
let progressStr: string;
let koProgress = '';
let percentage: number;
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const page = (currentProgress.section?.current ?? 0) + 1;
const totalPages = currentProgress.section?.total ?? 0;
progressStr = page.toString();
percentage = totalPages > 0 ? page / totalPages : 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const page = progress.section?.current ?? 0;
const totalPages = progress.section?.total ?? 0;
koProgress = page.toString();
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
} else {
progressStr = currentProgress.location;
const view = getView(bookKey);
if (view && progressStr) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(progressStr);
progressStr = xpointerResult.xpointer;
}
} catch (error) {
console.error(
'Failed to convert CFI to XPointer. Progress will be sent as percentage only.',
error,
);
const cfi = progress.location;
if (!view || !cfi) return null;
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(cfi);
koProgress = xpointerResult.xpointer;
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
}
const page = currentProgress.pageinfo?.current ?? 0;
const totalPages = currentProgress.pageinfo?.total ?? 0;
const page = progress.pageinfo?.current ?? 0;
const totalPages = progress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { progressStr, percentage };
return { koProgress, percentage };
}, [bookKey, getProgress, getBookData, getView]);
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
const view = getView(bookKey);
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress!, 10);
if (isNaN(pageToGo)) return;
view?.select(pageToGo - 1);
} else {
if (!remote.progress?.startsWith('/body')) return;
try {
const content = view?.renderer.getContents()[0];
const cfi = await getCFIFromXPointer(
remote.progress!,
content?.doc,
content?.index,
bookDoc,
);
view?.goTo(cfi);
} catch (error) {
console.error('Failed to convert XPointer to CFI', error);
return;
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
};
const promptedSync = async (
book: Book,
bookDoc: BookDoc,
local: BookProgress,
remote: KoSyncProgress,
) => {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = local.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress!, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = local.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${local.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: local.location, preview: localPreview },
remote: { ...remote, preview: remotePreview },
});
};
const pushProgress = useMemo(
() =>
debounce(async () => {
const { settings: currentSettings } = useSettingsStore.getState();
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
const { settings } = useSettingsStore.getState();
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
const currentBook = getBookData(bookKey)?.book;
const progress = generateKOProgress();
if (!currentBook || !progress || !progress.koProgress) return;
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = currentSettings;
if (
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
['receive', 'disable'].includes(koreaderSyncStrategy) ||
!currentBook
)
return;
const getDocumentDigest = (bookToDigest: Book): string => {
if (currentSettings.koreaderSyncChecksumMethod === 'filename') {
const filename = bookToDigest.sourceTitle || bookToDigest.title;
const normalizedPath = filename.replace(/\\/g, '/');
return md5(
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath,
);
}
return bookToDigest.hash;
};
const getDeviceName = async () => {
if (currentSettings.koreaderSyncDeviceName) return currentSettings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const digest = getDocumentDigest(currentBook);
const progressData = mapProgressToServerFormat();
if (!digest || !progressData) return;
if (progressData.progressStr === lastPushedCfiRef.current) return;
const deviceName = await getDeviceName();
const client = new KOSyncClient(
currentSettings.koreaderSyncServerUrl,
currentSettings.koreaderSyncUsername,
currentSettings.koreaderSyncUserkey,
currentSettings.koreaderSyncChecksumMethod,
currentSettings.koreaderSyncDeviceId,
deviceName,
);
await client.updateProgress(currentBook, progressData.progressStr, progressData.percentage);
lastPushedCfiRef.current = progressData.progressStr;
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
}, 5000),
[bookKey, appService, getBookData, mapProgressToServerFormat],
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient, generateKOProgress],
);
useEffect(() => {
const handleFlush = (event: CustomEvent) => {
const { bookKey: syncBookKey } = event.detail;
if (syncBookKey === bookKey) {
pushProgress.flush();
}
};
eventDispatcher.on('flush-koreader-sync', handleFlush);
return () => {
eventDispatcher.off('flush-koreader-sync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
const pullProgress = useCallback(
async () => {
if (!progress?.location || !appService || !kosyncClient) return;
useEffect(() => {
const performInitialSync = async () => {
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
if (
!book ||
!bookDoc ||
!progress ||
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
koreaderSyncStrategy === 'disabled'
)
return;
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
if (!book || !bookDoc) return;
if (koreaderSyncStrategy === 'send') {
syncCompletedForKey.current = bookKey;
const { strategy, enabled } = settings.kosync;
if (!enabled) return;
hasPulledOnce.current = true;
if (strategy === 'send') {
setSyncState('synced');
return;
}
setSyncState('checking');
const getDeviceName = async () => {
if (settings.koreaderSyncDeviceName) return settings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const deviceName = await getDeviceName();
const client = new KOSyncClient(
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncUserkey,
settings.koreaderSyncChecksumMethod,
settings.koreaderSyncDeviceId,
deviceName,
);
const remote = await client.getProgress(book);
lastPushedCfiRef.current = progress.location;
if (!remote?.progress || !remote?.timestamp) {
syncCompletedForKey.current = bookKey;
const remoteProgress = await kosyncClient.getProgress(book);
if (!remoteProgress || !remoteProgress.progress || !remoteProgress.timestamp) {
setSyncState('synced');
if (settings.koreaderSyncStrategy !== 'receive') {
pushProgress();
pushProgress.flush();
}
return;
}
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
const localIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? (parseInt(remote.progress, 10) - 1).toString()
: remote.progress.startsWith('epubcfi')
? remote.progress
: null;
const isRemoteCFI = remoteIdentifier?.startsWith('epubcfi');
let isProgressIdentical = false;
if (isLocalCFI && isRemoteCFI) {
isProgressIdentical = localIdentifier === remoteIdentifier;
}
if (!isProgressIdentical) {
const localPercentage = mapProgressToServerFormat()?.percentage ?? 0;
const remotePercentage = remote.percentage;
if (remotePercentage !== undefined && remotePercentage !== null) {
const tolerance = settings.koreaderSyncPercentageTolerance;
const percentageDifference = Math.abs(localPercentage - remotePercentage);
isProgressIdentical = percentageDifference < tolerance;
}
}
if (isProgressIdentical) {
lastPushedCfiRef.current = localIdentifier;
syncCompletedForKey.current = bookKey;
const remoteIsNewer = remoteProgress.timestamp * 1000 > localTimestamp;
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
applyRemoteProgress(book, bookDoc, remoteProgress);
setSyncState('synced');
return;
}
if (
settings.koreaderSyncStrategy === 'receive' ||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
) {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress, 10);
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
} else {
const isXPointer = remote.progress.startsWith('/body');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
} catch (error) {
console.error(
'Failed to convert XPointer to CFI, falling back to percentage.',
error,
);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else {
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
}
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
};
applyRemoteProgress();
syncCompletedForKey.current = bookKey;
setSyncState('synced');
} else if (settings.koreaderSyncStrategy === 'prompt') {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = progress.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = progress.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${progress.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: progress.location, preview: localPreview },
remote: { ...remote, preview: remotePreview, percentage: remote.percentage },
});
} else if (strategy === 'prompt') {
promptedSync(book, bookDoc, progress, remoteProgress);
setSyncState('conflict');
} else {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
}
};
if (bookKey && book && progress && syncCompletedForKey.current !== bookKey) {
syncCompletedForKey.current = bookKey;
performInitialSync();
}
}, [
bookKey,
book,
bookDoc,
progress,
settings,
appService,
getBookData,
getProgress,
getView,
mapProgressToServerFormat,
pushProgress,
_,
bookData?.config?.updatedAt,
]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient, settings.kosync, progress],
);
useEffect(() => {
const handlePullProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pullProgress();
};
const handlePushProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress();
};
const handleFlush = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress.flush();
};
eventDispatcher.on('pull-kosync', handlePullProgress);
eventDispatcher.on('push-kosync', handlePushProgress);
eventDispatcher.on('flush-kosync', handleFlush);
return () => {
eventDispatcher.off('pull-kosync', handlePullProgress);
eventDispatcher.off('push-kosync', handlePushProgress);
eventDispatcher.off('flush-kosync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress, pullProgress]);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!appService || !kosyncClient || !progress?.location) return;
if (hasPulledOnce.current) return;
pullProgress();
}, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (syncState === 'synced' && progress) {
if (
settings.koreaderSyncStrategy !== 'receive' &&
settings.koreaderSyncStrategy !== 'disabled'
) {
const { strategy, enabled } = settings.kosync;
if (strategy !== 'receive' && enabled) {
pushProgress();
}
}
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
}, [progress, syncState, settings.kosync, pushProgress]);
const resolveConflictWithLocal = () => {
pushProgress();
@@ -421,50 +290,12 @@ export const useKOSync = (bookKey: string) => {
const resolveConflictWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const currentBook = conflictDetails?.book;
const book = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
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;
const remoteTotalPages =
remotePercentage > 0 ? Math.round(remotePage / remotePercentage) : 0;
if (!book || !bookDoc || !remote || !remote.progress || !view) return;
if (!isNaN(remotePage) && Math.abs(localTotalPages - remoteTotalPages) <= 1) {
console.log('Going to remote page:', remotePage);
view.select(remotePage - 1);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
console.log('Going to remote percentage:', remote.percentage);
view.goToFraction(remote.percentage);
}
} else {
const isXPointer = remote.progress.startsWith('/body');
const isCFI = remote.progress.startsWith('epubcfi');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
}
} catch (error) {
console.error('Failed to convert XPointer to CFI, falling back to percentage.', error);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else if (isCFI) {
view.goTo(remote.progress);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
}
applyRemoteProgress(book, bookDoc, remote);
setSyncState('synced');
setConflictDetails(null);
};
@@ -473,8 +304,9 @@ export const useKOSync = (bookKey: string) => {
syncState,
conflictDetails,
errorMessage,
pushProgress,
pullProgress,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
};
};