mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
* feat(kosync): defer push after resume * PR feedback updates: * follow established patterns from other hooks * error handling * fix typos
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
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, BookProgress, 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';
|
|
|
|
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
|
|
|
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
|
|
|
|
export interface SyncDetails {
|
|
book: Book;
|
|
bookDoc: BookDoc;
|
|
local: {
|
|
cfi?: string;
|
|
preview: string;
|
|
};
|
|
remote: KoSyncProgress & {
|
|
preview: string;
|
|
percentage?: number;
|
|
};
|
|
}
|
|
|
|
export const useKOSync = (bookKey: string) => {
|
|
const _ = useTranslation();
|
|
const { appService } = useEnv();
|
|
const { settings } = useSettingsStore();
|
|
const { getProgress, getView } = useReaderStore();
|
|
const { getBookData } = useBookDataStore();
|
|
|
|
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 progress = getProgress(bookKey);
|
|
|
|
useEffect(() => {
|
|
if (!settings.kosync.username || !settings.kosync.userkey) {
|
|
setKOSyncClient(null);
|
|
return;
|
|
}
|
|
const client = new KOSyncClient(settings.kosync);
|
|
setKOSyncClient(client);
|
|
}, [settings]);
|
|
|
|
const generateKOProgress = useCallback(() => {
|
|
const progress = getProgress(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (!progress || !bookData) return null;
|
|
|
|
let koProgress = '';
|
|
let percentage: number;
|
|
if (bookData.isFixedLayout) {
|
|
const page = progress.section?.current ?? 0;
|
|
const totalPages = progress.section?.total ?? 0;
|
|
koProgress = page.toString();
|
|
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
|
} else {
|
|
const view = getView(bookKey);
|
|
const cfi = progress.location;
|
|
if (!view || !cfi) return null;
|
|
try {
|
|
const koContents = view.renderer.getContents();
|
|
const koPrimaryIdx = view.renderer.primaryIndex;
|
|
const content = koContents.find((x) => x.index === koPrimaryIdx) ?? koContents[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 = progress.pageinfo?.current ?? 0;
|
|
const totalPages = progress.pageinfo?.total ?? 0;
|
|
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
|
}
|
|
|
|
return { koProgress, percentage };
|
|
}, [bookKey, getProgress, getBookData, getView]);
|
|
|
|
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
|
|
const view = getView(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (!view || !bookData) return;
|
|
|
|
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()
|
|
.find((x) => x.index === view?.renderer.primaryIndex);
|
|
const koProgress = remote.progress;
|
|
const cfi = await getCFIFromXPointer(koProgress, 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;
|
|
const conflictProgressDiffThreshold = 0.0001;
|
|
let showConflictDetails = false;
|
|
|
|
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
|
const localPageInfo = local.section;
|
|
const localPercentage =
|
|
localPageInfo && localPageInfo.total > 0
|
|
? (localPageInfo.current + 1) / localPageInfo.total
|
|
: 0;
|
|
localPreview = localPageInfo
|
|
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
|
page: localPageInfo.current + 1,
|
|
total: localPageInfo.total,
|
|
percentage: Math.round(localPercentage * 100),
|
|
})
|
|
: _('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),
|
|
});
|
|
}
|
|
showConflictDetails =
|
|
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
|
|
} else {
|
|
remotePreview = _('Approximately {{percentage}}%', {
|
|
percentage: Math.round(remotePercentage * 100),
|
|
});
|
|
}
|
|
} else {
|
|
const localPageInfo = local.pageinfo;
|
|
const localPercentage =
|
|
localPageInfo && localPageInfo.total > 0
|
|
? (localPageInfo.current + 1) / localPageInfo.total
|
|
: 0;
|
|
localPreview = `${local.sectionLabel} (${Math.round(localPercentage * 100)}%)`;
|
|
|
|
remotePreview = _('Approximately {{percentage}}%', {
|
|
percentage: Math.round(remotePercentage * 100),
|
|
});
|
|
showConflictDetails =
|
|
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
|
|
}
|
|
|
|
if (showConflictDetails) {
|
|
setConflictDetails({
|
|
book,
|
|
bookDoc,
|
|
local: { cfi: local.location, preview: localPreview },
|
|
remote: { ...remote, preview: remotePreview },
|
|
});
|
|
}
|
|
};
|
|
|
|
const pushProgress = useMemo(
|
|
() =>
|
|
debounce(async () => {
|
|
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;
|
|
|
|
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
|
|
}, 5000),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[bookKey, appService, kosyncClient],
|
|
);
|
|
|
|
const pullProgress = useCallback(
|
|
async () => {
|
|
if (!progress?.location || !appService || !kosyncClient) return;
|
|
|
|
const bookData = getBookData(bookKey);
|
|
const book = bookData?.book;
|
|
const bookDoc = bookData?.bookDoc;
|
|
if (!book || !bookDoc) return;
|
|
|
|
const { strategy, enabled } = settings.kosync;
|
|
if (!enabled) return;
|
|
|
|
hasPulledOnce.current = true;
|
|
if (strategy === 'send') {
|
|
setSyncState('synced');
|
|
return;
|
|
}
|
|
|
|
setSyncState('checking');
|
|
const remoteProgress = await kosyncClient.getProgress(book);
|
|
if (!remoteProgress || !remoteProgress.progress) {
|
|
setSyncState('synced');
|
|
return;
|
|
}
|
|
|
|
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
|
|
const remoteTimestamp = remoteProgress.timestamp
|
|
? remoteProgress.timestamp * 1000
|
|
: Date.now();
|
|
const remoteIsNewer = remoteTimestamp > localTimestamp;
|
|
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
|
|
applyRemoteProgress(book, bookDoc, remoteProgress);
|
|
setSyncState('synced');
|
|
} else if (strategy === 'prompt') {
|
|
promptedSync(book, bookDoc, progress, remoteProgress);
|
|
setSyncState('conflict');
|
|
} else {
|
|
setSyncState('synced');
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[bookKey, appService, kosyncClient, settings.kosync, progress],
|
|
);
|
|
|
|
// use a ref to track the current push/pull functions so they can change without triggering effects
|
|
const syncRefs = useRef({ pushProgress, pullProgress });
|
|
useEffect(() => {
|
|
syncRefs.current = { pushProgress, pullProgress };
|
|
}, [pushProgress, pullProgress]);
|
|
|
|
useEffect(() => {
|
|
const handlePushProgress = (event: CustomEvent) => {
|
|
const { pushProgress } = syncRefs.current;
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
};
|
|
const handleFlush = (event: CustomEvent) => {
|
|
const { pushProgress } = syncRefs.current;
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
pushProgress.flush();
|
|
};
|
|
eventDispatcher.on('push-kosync', handlePushProgress);
|
|
eventDispatcher.on('flush-kosync', handleFlush);
|
|
return () => {
|
|
const { pushProgress } = syncRefs.current;
|
|
eventDispatcher.off('push-kosync', handlePushProgress);
|
|
eventDispatcher.off('flush-kosync', handleFlush);
|
|
pushProgress.flush();
|
|
};
|
|
}, [bookKey]);
|
|
|
|
useEffect(() => {
|
|
const handlePullProgress = (event: CustomEvent) => {
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
const { pullProgress } = syncRefs.current;
|
|
pullProgress();
|
|
};
|
|
eventDispatcher.on('pull-kosync', handlePullProgress);
|
|
return () => {
|
|
eventDispatcher.off('pull-kosync', handlePullProgress);
|
|
};
|
|
}, [bookKey]);
|
|
|
|
// Pull: pull progress once when the book is opened
|
|
useEffect(() => {
|
|
if (!appService || !kosyncClient || !progress?.location) return;
|
|
if (hasPulledOnce.current) return;
|
|
|
|
syncRefs.current.pullProgress();
|
|
}, [appService, kosyncClient, progress?.location]);
|
|
|
|
// Push: auto-push progress when progress changes with a debounce
|
|
useEffect(() => {
|
|
if (syncState === 'synced' && progress) {
|
|
const { strategy, enabled } = settings.kosync;
|
|
if (strategy !== 'receive' && enabled) {
|
|
syncRefs.current.pushProgress();
|
|
}
|
|
}
|
|
}, [progress, syncState, settings.kosync]);
|
|
|
|
useWindowActiveChanged((isActive) => {
|
|
const { pushProgress, pullProgress } = syncRefs.current;
|
|
|
|
if (isActive) {
|
|
hasPulledOnce.current = false;
|
|
pullProgress();
|
|
} else {
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
}
|
|
});
|
|
|
|
const resolveWithLocal = () => {
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
setSyncState('synced');
|
|
setConflictDetails(null);
|
|
};
|
|
|
|
const resolveWithRemote = async () => {
|
|
const view = getView(bookKey);
|
|
const remote = conflictDetails?.remote;
|
|
const book = conflictDetails?.book;
|
|
const bookDoc = conflictDetails?.bookDoc;
|
|
|
|
if (!book || !bookDoc || !remote || !remote.progress || !view) return;
|
|
|
|
applyRemoteProgress(book, bookDoc, remote);
|
|
setSyncState('synced');
|
|
setConflictDetails(null);
|
|
};
|
|
|
|
return {
|
|
syncState,
|
|
conflictDetails,
|
|
errorMessage,
|
|
pushProgress,
|
|
pullProgress,
|
|
resolveWithLocal,
|
|
resolveWithRemote,
|
|
};
|
|
};
|