fix(kosync): resolve progress CFI via its own spine section (#4364)
generateKOProgress built its XCFI converter from the paginator's
primaryIndex and the rendered primary document, then converted
progress.location. Because #primaryIndex can lag behind the viewport
during scrolling, the CFI's spine section could differ from the
converter's, tripping XCFI's guard ("CFI spine index N does not match
converter spine index M") and silently dropping the progress push.
Route through getXPointerFromCFI, which keys off the CFI's own spine
index and loads the correct section's document from the book when the
rendered index doesn't match. Fall back to the cached config.xpointer
on failure.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XCFI, getXPointerFromCFI } from '@/utils/xcfi';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
const createDocument = (htmlString: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(htmlString, 'text/html');
|
||||
};
|
||||
|
||||
// BookDoc stub that respects sparse spine indices so sections[13] can be
|
||||
// populated without filling 0..12.
|
||||
const makeBookDoc = (docsByIndex: Record<number, Document>): BookDoc => {
|
||||
const sections: Array<{ createDocument: () => Promise<Document> }> = [];
|
||||
for (const [index, doc] of Object.entries(docsByIndex)) {
|
||||
sections[Number(index)] = { createDocument: async () => doc };
|
||||
}
|
||||
return { sections } as unknown as BookDoc;
|
||||
};
|
||||
|
||||
// Regression for the KOSync progress-push crash:
|
||||
// "CFI spine index 13 does not match converter spine index 11".
|
||||
//
|
||||
// The paginator's #primaryIndex can lag behind the viewport during scrolling,
|
||||
// so progress.location can be a CFI in a different spine section than the
|
||||
// currently-rendered primary view. generateKOProgress used to build its XCFI
|
||||
// converter from the primary view's document/index and convert the CFI
|
||||
// directly, which throws whenever the two diverge.
|
||||
describe('XCFI KOSync primary-index lag', () => {
|
||||
// Spine step 28 -> 0-based index 13 -> DocFragment[14].
|
||||
// Path after '!': /4 = body, /2 = first element child (p), :5 = text offset.
|
||||
const cfi = 'epubcfi(/6/28!/4/2:5)';
|
||||
const primaryDoc = createDocument('<html><body><p>Stale primary section.</p></body></html>');
|
||||
const cfiDoc = createDocument('<html><body><p>First paragraph here.</p></body></html>');
|
||||
|
||||
it('reproduces the bug: converting against the lagging primary document throws', () => {
|
||||
const converter = new XCFI(primaryDoc, 11);
|
||||
expect(() => converter.cfiToXPointer(cfi)).toThrow(/spine index 13 does not match/);
|
||||
});
|
||||
|
||||
it('resolves the CFI via its own spine section instead of the primary view', async () => {
|
||||
const bookDoc = makeBookDoc({ 13: cfiDoc });
|
||||
|
||||
// Mirrors the fixed call: pass the lagging primary doc/index, but the
|
||||
// helper loads the CFI's actual section (13) from the book.
|
||||
const xpointer = await getXPointerFromCFI(cfi, primaryDoc, 11, bookDoc);
|
||||
expect(xpointer.xpointer).toBe('/body/DocFragment[14]/body/p/text().5');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ 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 { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
|
||||
import { getLocalProgressPreview, getProgressPercentage } from './kosyncPreview';
|
||||
import { getRemoteFraction, isXPointerProgress } from './kosyncProgress';
|
||||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||||
@@ -53,7 +53,7 @@ export const useKOSync = (bookKey: string) => {
|
||||
setKOSyncClient(client);
|
||||
}, [settings]);
|
||||
|
||||
const generateKOProgress = useCallback(() => {
|
||||
const generateKOProgress = useCallback(async () => {
|
||||
const progress = getProgress(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!progress || !bookData) return null;
|
||||
@@ -74,17 +74,22 @@ export const useKOSync = (bookKey: string) => {
|
||||
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;
|
||||
setConfig(bookKey, { xpointer: koProgress });
|
||||
} else if (config?.xpointer) {
|
||||
koProgress = config.xpointer;
|
||||
}
|
||||
// progress.location may be a CFI in a different spine section than the
|
||||
// currently-rendered primary view (#primaryIndex can lag behind the
|
||||
// viewport while scrolling). Resolve against the CFI's own section
|
||||
// rather than forcing the primary view's document, which throws on a
|
||||
// spine-index mismatch.
|
||||
const xpointerResult = await getXPointerFromCFI(
|
||||
cfi,
|
||||
content?.doc,
|
||||
content?.index,
|
||||
bookData.bookDoc ?? undefined,
|
||||
);
|
||||
koProgress = xpointerResult.xpointer;
|
||||
setConfig(bookKey, { xpointer: koProgress });
|
||||
} catch (error) {
|
||||
console.error('Failed to convert CFI to XPointer', error);
|
||||
if (config?.xpointer) koProgress = config.xpointer;
|
||||
}
|
||||
|
||||
const page = progress.pageinfo?.current ?? 0;
|
||||
@@ -210,7 +215,7 @@ export const useKOSync = (bookKey: string) => {
|
||||
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
|
||||
|
||||
const currentBook = getBookData(bookKey)?.book;
|
||||
const progress = generateKOProgress();
|
||||
const progress = await generateKOProgress();
|
||||
if (!currentBook || !progress || !progress.koProgress) return;
|
||||
|
||||
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
|
||||
|
||||
Reference in New Issue
Block a user