fix(reader): preserve exact positions when syncing with koreader (#1694)

Convert complete source CFIs on the server to avoid chapter-start resets
and browser-generated locators that disagree with CREngine. Preserve
engine whitespace, Unicode offsets, element anchors and long text nodes.
Handle prefixed OPF namespaces and retry outdated annotation conversions.

Closes #1621
Closes #1288
This commit is contained in:
neon
2026-10-06 11:22:55 -06:00
committed by GitHub
parent 8fecae94e6
commit e253f7ffca
13 changed files with 524 additions and 249 deletions
@@ -204,6 +204,59 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () =>
expect(stored?.percentage).toBeCloseTo(80, 5);
});
it.each(['web', 'narration'])('restores the complete %s reader position and retains its text offset on device upload', async (source) => {
await ctx.db
.update(schema.koreaderDeviceProgress)
.set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') })
.where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
const cfi = 'epubcfi(/6/2!/4/2/1:3)';
// Narration only advances a position; begin behind the position being restored.
await saveFromWebReader(epub.bookFileId, { percentage: 10, cfi: 'epubcfi(/6/2!/4/2/1:0)' });
await saveFromWebReader(epub.bookFileId, { percentage: 55, cfi, ...(source === 'narration' ? { source } : {}) });
const pull = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/syncs/progress/${epubHash}`,
headers: deviceHeaders(),
});
expect(pull.statusCode).toBe(200);
const remote = pull.json() as { percentage: number; progress: string; device: string };
expect(remote).toEqual(expect.objectContaining({ percentage: 0.55, progress: '/body/DocFragment[1]/body/p/text().3', device: 'web' }));
// Readers paginate differently, but the text point must survive the lifecycle upload.
await syncFromDevice(epubHash, 0.52, remote.progress);
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi, percentage: 52 }));
expect((await storedProgress(epub.bookFileId))?.koreaderProgress).toBe(remote.progress);
});
it('preserves positions through repeated web and native-engine handoffs', async () => {
for (let offset = 1; offset <= 4; offset += 1) {
const cfi = `epubcfi(/6/2!/4/2/1:${offset})`;
await saveFromWebReader(epub.bookFileId, { percentage: 20 + offset, cfi });
const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders() });
expect(pull.statusCode).toBe(200);
expect(pull.json().progress).toBe(`/body/DocFragment[1]/body/p/text().${offset}`);
const nextOffset = offset + 1;
await syncFromDevice(epubHash, (25 + offset) / 100, `/body[1]/DocFragment[1]/body[1]/p[1]/text()[1].${nextOffset}`);
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi: `epubcfi(/6/2!/4/2/1:${nextOffset})` }));
}
});
it('serves percentage fallback for an unresolvable reader position', async () => {
await ctx.db
.update(schema.koreaderDeviceProgress)
.set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') })
.where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
await saveFromWebReader(epub.bookFileId, { percentage: 30, cfi: 'epubcfi(/6/2!/4/999/1:3)' });
const pull = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/syncs/progress/${epubHash}`,
headers: deviceHeaders(),
});
expect(pull.statusCode).toBe(200);
expect(pull.json()).toEqual(expect.objectContaining({ progress: null, percentage: 0.3, device: 'web' }));
});
it('never turns KOSync progress pushes into reading sessions', async () => {
await syncFromDevice(epubHash, 0.4, XPOINTER);
await ctx.db