fix(kosync): reflowable conflict comparison via local CFI; scrolled-mode + library fixes (#4367)
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared. Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): correct scrolled-mode reopen drift over background-image sections Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): redirect to login on pull-to-refresh when signed out Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(memory): add kosync conflict + toc/scrolled-restore notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): prevent CFI crash on inert-only section bodies Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(kosync): keep auto-push working when a pull finds no real conflict In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): show most recent sync time and reorder settings tabs Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,12 @@ import { BookDoc } from '@/libs/document';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
|
||||
import { getLocalProgressPreview, getProgressPercentage } from './kosyncPreview';
|
||||
import { getRemoteFraction, isXPointerProgress } from './kosyncProgress';
|
||||
import {
|
||||
formatProgressPercentage,
|
||||
getLocalProgressPreview,
|
||||
getProgressPercentage,
|
||||
} from './kosyncPreview';
|
||||
import { getRemoteFraction, getRemoteLocalFraction, isXPointerProgress } from './kosyncProgress';
|
||||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||||
|
||||
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
|
||||
@@ -154,7 +158,14 @@ export const useKOSync = (bookKey: string) => {
|
||||
) => {
|
||||
let remotePreview = '';
|
||||
const remotePercentage = remote.percentage || 0;
|
||||
const conflictProgressDiffThreshold = 0.0001;
|
||||
// Progress last pushed from this same device is just our own earlier
|
||||
// position; only treat a sizeable jump (≥1%) as a conflict so we don't
|
||||
// prompt on the sub-page drift between a push and the next pull.
|
||||
const isSameDevice = !!remote.device_id && remote.device_id === settings.kosync.deviceId;
|
||||
const conflictProgressDiffThreshold = isSameDevice ? 0.01 : 0.0001;
|
||||
// The remote progress as a percentage to compare against the local one;
|
||||
// refined to a locally-resolved fraction for reflowable books below.
|
||||
let remoteComparePercentage = remotePercentage;
|
||||
let showConflictDetails = false;
|
||||
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
|
||||
|
||||
@@ -173,28 +184,36 @@ export const useKOSync = (bookKey: string) => {
|
||||
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
percentage: formatProgressPercentage(remotePercentage),
|
||||
});
|
||||
} else {
|
||||
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
percentage: formatProgressPercentage(remotePercentage),
|
||||
});
|
||||
}
|
||||
showConflictDetails =
|
||||
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
|
||||
} else {
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
percentage: formatProgressPercentage(remotePercentage),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// KOReader's reported percentage comes from its own pagination, so it's
|
||||
// not directly comparable to Readest's progress. Resolve the remote
|
||||
// position to a local fraction for an apples-to-apples comparison and
|
||||
// fall back to the reported percentage only when it can't be resolved
|
||||
// locally (non-XPointer progress or a missing section).
|
||||
const view = getView(bookKey);
|
||||
const localFraction = view ? await getRemoteLocalFraction(remote, view, bookDoc) : undefined;
|
||||
remoteComparePercentage = localFraction ?? remotePercentage;
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
percentage: formatProgressPercentage(remoteComparePercentage),
|
||||
});
|
||||
showConflictDetails =
|
||||
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
|
||||
Math.abs(localPercentage - remoteComparePercentage) > conflictProgressDiffThreshold;
|
||||
}
|
||||
|
||||
if (showConflictDetails) {
|
||||
@@ -205,6 +224,7 @@ export const useKOSync = (bookKey: string) => {
|
||||
remote: { ...remote, preview: remotePreview },
|
||||
});
|
||||
}
|
||||
return showConflictDetails;
|
||||
};
|
||||
|
||||
const pushProgress = useMemo(
|
||||
@@ -218,6 +238,7 @@ export const useKOSync = (bookKey: string) => {
|
||||
const progress = await generateKOProgress();
|
||||
if (!currentBook || !progress || !progress.koProgress) return;
|
||||
|
||||
console.log('[KOSync] Pushing progress');
|
||||
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
|
||||
}, 5000),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -248,6 +269,7 @@ export const useKOSync = (bookKey: string) => {
|
||||
setSyncState('synced');
|
||||
return;
|
||||
}
|
||||
console.log('[KOSync] Pulled remote progress', { bookKey, remoteProgress });
|
||||
|
||||
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
|
||||
const remoteTimestamp = remoteProgress.timestamp
|
||||
@@ -258,8 +280,10 @@ export const useKOSync = (bookKey: string) => {
|
||||
applyRemoteProgress(book, bookDoc, remoteProgress);
|
||||
setSyncState('synced');
|
||||
} else if (strategy === 'prompt') {
|
||||
promptedSync(book, bookDoc, progress, remoteProgress);
|
||||
setSyncState('conflict');
|
||||
// Only stay in the conflict state when there's an actual conflict to
|
||||
// resolve; otherwise return to 'synced' so auto-push keeps working.
|
||||
const hasConflict = await promptedSync(book, bookDoc, progress, remoteProgress);
|
||||
setSyncState(hasConflict ? 'conflict' : 'synced');
|
||||
} else {
|
||||
setSyncState('synced');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user