mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -102,6 +102,7 @@ describe('KoreaderService', () => {
|
||||
};
|
||||
let mockPositionConverter: {
|
||||
xpointerPointToCfi: ReturnType<typeof vi.fn>;
|
||||
cfiPointToXpointer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockBookService: {
|
||||
syncKoboReadingStateForExternalProgress: ReturnType<typeof vi.fn>;
|
||||
@@ -185,6 +186,7 @@ describe('KoreaderService', () => {
|
||||
|
||||
mockPositionConverter = {
|
||||
xpointerPointToCfi: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
|
||||
cfiPointToXpointer: vi.fn().mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' }),
|
||||
};
|
||||
|
||||
mockBookService = {
|
||||
@@ -1038,27 +1040,27 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('converts CFI to DocFragment XPointer using chapter service (no file I/O)', async () => {
|
||||
it('converts the complete reader CFI rather than returning the chapter start', async () => {
|
||||
const readerTime = new Date('2026-02-01T11:00:00.000Z');
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
|
||||
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
|
||||
mockRepo.getReadingProgress.mockResolvedValue({
|
||||
percentage: 50,
|
||||
// /6/4 -> spinePos=4 -> floor(4/2)-1 = 1 -> chapterIndex=1 -> DocFragment[2]
|
||||
cfi: 'epubcfi(/6/4!/4/2/2:10)',
|
||||
cfi: 'epubcfi(/6/4!/4/2/1:10)',
|
||||
updatedAt: readerTime,
|
||||
});
|
||||
mockChapterService.parseChapterIndexFromCfi.mockReturnValue(1);
|
||||
mockPositionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'exact', pos0: '/body/DocFragment[2]/body/p/text().10' });
|
||||
|
||||
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual({
|
||||
document: 'doc-hash',
|
||||
percentage: 0.5,
|
||||
progress: '/body/DocFragment[2]/body',
|
||||
progress: '/body/DocFragment[2]/body/p/text().10',
|
||||
device: 'web',
|
||||
device_id: 'bookorbit-web',
|
||||
timestamp: Math.floor(readerTime.getTime() / 1000),
|
||||
});
|
||||
expect(mockChapterService.parseChapterIndexFromCfi).toHaveBeenCalledWith('epubcfi(/6/4!/4/2/2:10)');
|
||||
expect(mockPositionConverter.cfiPointToXpointer).toHaveBeenCalledWith({ bookFileId: 10, cfi: 'epubcfi(/6/4!/4/2/1:10)' });
|
||||
expect(mockChapterService.parseChapterIndexFromCfi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns exact web reader KOReader XPointer when it is stored', async () => {
|
||||
@@ -1080,10 +1082,10 @@ describe('KoreaderService', () => {
|
||||
device_id: 'bookorbit-web',
|
||||
timestamp: Math.floor(readerTime.getTime() / 1000),
|
||||
});
|
||||
expect(mockChapterService.parseChapterIndexFromCfi).not.toHaveBeenCalled();
|
||||
expect(mockPositionConverter.cfiPointToXpointer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null XPointer when chapter service cannot parse CFI spine index', async () => {
|
||||
it('returns percentage fallback when the converter cannot resolve a CFI', async () => {
|
||||
const readerTime = new Date('2026-02-01T11:00:00.000Z');
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
|
||||
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
|
||||
@@ -1092,10 +1094,42 @@ describe('KoreaderService', () => {
|
||||
cfi: 'some-unparseable-format',
|
||||
updatedAt: readerTime,
|
||||
});
|
||||
mockChapterService.parseChapterIndexFromCfi.mockReturnValue(null);
|
||||
mockPositionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'failed', reason: 'unparsable_cfi' });
|
||||
|
||||
const result = await service.getProgress(7, 'doc-hash');
|
||||
expect(result?.progress).toBeNull();
|
||||
expect(result).toEqual(expect.objectContaining({ progress: null, percentage: 0.3, device: 'web' }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 'failed', reason: 'chapter_unavailable', pos0: '/body/DocFragment[1]/body' },
|
||||
{ status: 'exact' },
|
||||
{ status: 'exact', pos0: '' },
|
||||
])('never serves a guessed or missing position: %j', async (outcome) => {
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format: 'epub' });
|
||||
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
|
||||
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 10.53, cfi: 'epubcfi(/6/2!/4/82/1:50)', updatedAt: new Date() });
|
||||
mockPositionConverter.cfiPointToXpointer.mockResolvedValue(outcome);
|
||||
|
||||
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.1053 }));
|
||||
});
|
||||
|
||||
it('keeps percentage fallback available when chapter loading throws', async () => {
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format: 'epub' });
|
||||
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
|
||||
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 10.53, cfi: 'epubcfi(/6/2!/4/82/1:50)', updatedAt: new Date() });
|
||||
mockPositionConverter.cfiPointToXpointer.mockRejectedValue(new Error('chapter unavailable'));
|
||||
|
||||
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.1053 }));
|
||||
expect(Logger.prototype.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['pdf', 'cbz'])('does not send an EPUB pointer to a paged %s reader', async (format) => {
|
||||
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, format });
|
||||
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
|
||||
mockRepo.getReadingProgress.mockResolvedValue({ percentage: 25, cfi: 'epubcfi(/6/2!/4/2/1:5)', updatedAt: new Date() });
|
||||
|
||||
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual(expect.objectContaining({ progress: null, percentage: 0.25 }));
|
||||
expect(mockPositionConverter.cfiPointToXpointer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when neither device nor web reader progress exists', async () => {
|
||||
|
||||
@@ -516,10 +516,16 @@ export class KoreaderService {
|
||||
|
||||
if (readingProg) {
|
||||
let xpointer = readingProg.koreaderProgress ?? null;
|
||||
if (!xpointer && readingProg.cfi) {
|
||||
const chapterIndex = this.chapterService.parseChapterIndexFromCfi(readingProg.cfi);
|
||||
if (chapterIndex !== null && chapterIndex >= 0) {
|
||||
xpointer = `/body/DocFragment[${chapterIndex + 1}]/body`;
|
||||
if (!xpointer && readingProg.cfi && !isPagedReadingFormat(bookFile.format)) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const outcome = await this.positionConverter.cfiPointToXpointer({ bookFileId: bookFile.id, cfi: readingProg.cfi });
|
||||
// A chapter start would override the percentage fallback and discard the text offset.
|
||||
if (outcome.status !== 'failed' && outcome.pos0) xpointer = outcome.pos0;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[${SYNC_EVENT}] [fail] userId=${userId} bookFileId=${bookFile.id} durationMs=${Date.now() - startedAt} errorClass=${error instanceof Error ? sanitizeLogValue(error.name) : 'unknown'} - position conversion failed; serving percentage fallback`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/**
|
||||
* Collapsed-text index over a chapter DOM, approximating how crengine builds its
|
||||
* text nodes (whitespace runs collapse to single spaces, leading whitespace after
|
||||
* block boundaries drops). Every collapsed CODE POINT maps back to its raw text
|
||||
* node + UTF-16 offset, so positions can be translated between:
|
||||
* - crengine xpointer offsets (code points in the collapsed view of a text run)
|
||||
* - browser/CFI offsets (UTF-16 code units in the raw text nodes)
|
||||
* Synthetic separators are inserted between blocks for search quality; they map to
|
||||
* no raw position and are skipped when resolving range endpoints.
|
||||
* Chapter text index with separate search and CREngine offset mappings. Search
|
||||
* collapses Unicode whitespace and inserts block separators; engine offsets keep
|
||||
* Unicode spaces, leading ASCII space, and source-parser text node boundaries.
|
||||
* Both map Unicode code points back to raw DOM nodes and UTF-16 CFI offsets.
|
||||
*/
|
||||
|
||||
import { CfiNode, getDocumentElement, isElementNode, isTextNode } from './cfi.utils';
|
||||
@@ -61,6 +57,8 @@ export interface TextRun {
|
||||
runCount: number;
|
||||
collapsedStart: number;
|
||||
collapsedLength: number;
|
||||
engineRawOffsets: Int32Array;
|
||||
engineRawEnd: number;
|
||||
}
|
||||
|
||||
export interface RawPoint {
|
||||
@@ -95,10 +93,14 @@ export class ChapterTextIndex {
|
||||
private readonly runByPoint: Int32Array;
|
||||
private readonly rawOffsetByPoint: Int32Array;
|
||||
private readonly cpByUtf16: Int32Array;
|
||||
private readonly engineRunsByRun = new Map<TextRun, TextRun[]>();
|
||||
private readonly runsByParent = new Map<CfiNode, TextRun[]>();
|
||||
private readonly runInfoByNode = new Map<CfiNode, { run: TextRun; rawStart: number }>();
|
||||
|
||||
constructor(root: CfiNode) {
|
||||
constructor(
|
||||
root: CfiNode,
|
||||
private readonly engineBoundaries = new Map<CfiNode, number[]>(),
|
||||
) {
|
||||
const state: BuildState = {
|
||||
collapsedPoints: [],
|
||||
runByPoint: [],
|
||||
@@ -131,8 +133,31 @@ export class ChapterTextIndex {
|
||||
this.cpByUtf16[this.collapsed.length] = state.collapsedPoints.length;
|
||||
|
||||
for (const run of runs) {
|
||||
if (!run.engineRawOffsets.length) continue;
|
||||
const list = this.runsByParent.get(run.parent) ?? [];
|
||||
list.push(run);
|
||||
const splitOffsets = [0];
|
||||
let partStart = 0;
|
||||
let supported = true;
|
||||
for (const part of run.parts) {
|
||||
const boundaries = this.engineBoundaries.get(part.node);
|
||||
if (boundaries?.length === 0) supported = false;
|
||||
for (const offset of boundaries?.slice(1, -1) ?? []) splitOffsets.push(partStart + offset);
|
||||
partStart += part.rawLength;
|
||||
}
|
||||
splitOffsets.push(partStart);
|
||||
const engineRuns: TextRun[] = [];
|
||||
if (supported) {
|
||||
let nextOffset = 0;
|
||||
for (let i = 0; i < splitOffsets.length - 1; i += 1) {
|
||||
const firstOffset = nextOffset;
|
||||
while (nextOffset < run.engineRawOffsets.length && run.engineRawOffsets[nextOffset] < splitOffsets[i + 1]) nextOffset += 1;
|
||||
const offsets = run.engineRawOffsets.subarray(firstOffset, nextOffset);
|
||||
if (!offsets.length) continue;
|
||||
engineRuns.push(splitOffsets.length === 2 ? run : { ...run, engineRawOffsets: offsets, engineRawEnd: splitOffsets[i + 1] });
|
||||
}
|
||||
}
|
||||
this.engineRunsByRun.set(run, engineRuns);
|
||||
list.push(...engineRuns);
|
||||
this.runsByParent.set(run.parent, list);
|
||||
}
|
||||
for (const [, list] of this.runsByParent) {
|
||||
@@ -181,15 +206,28 @@ export class ChapterTextIndex {
|
||||
runCount: 0,
|
||||
collapsedStart: state.collapsedPoints.length,
|
||||
collapsedLength: 0,
|
||||
engineRawOffsets: new Int32Array(),
|
||||
engineRawEnd: 0,
|
||||
};
|
||||
const runIdx = runs.length;
|
||||
let emittedAny = false;
|
||||
let rawConcatOffset = 0;
|
||||
// Search drops block indentation; CREngine keeps one leading ASCII space and
|
||||
// preserves Unicode spaces. Keep its offsets independent of the search index.
|
||||
const engineRawOffsets: number[] = [];
|
||||
let engineLastWasSpace = false;
|
||||
const whitespaceOnly = run.parts.every((part) => /^[ \t\r\n]*$/.test(part.node.data ?? ''));
|
||||
|
||||
for (const part of run.parts) {
|
||||
this.runInfoByNode.set(part.node, { run, rawStart: rawConcatOffset });
|
||||
const raw = part.node.data ?? '';
|
||||
const boundaries = new Set(this.engineBoundaries.get(part.node) ?? []);
|
||||
const partStart = rawConcatOffset;
|
||||
for (const cp of raw) {
|
||||
if (boundaries.has(rawConcatOffset - partStart)) engineLastWasSpace = false;
|
||||
const engineSpace = /^[ \t\r\n]$/.test(cp);
|
||||
if (!whitespaceOnly && (!engineSpace || !engineLastWasSpace)) engineRawOffsets.push(rawConcatOffset);
|
||||
engineLastWasSpace = engineSpace;
|
||||
if (WHITESPACE_RE.test(cp)) {
|
||||
if (!state.atBoundary && !state.lastWasSpace) {
|
||||
state.collapsedPoints.push(' ');
|
||||
@@ -223,6 +261,8 @@ export class ChapterTextIndex {
|
||||
}
|
||||
|
||||
run.collapsedLength = emittedAny ? state.collapsedPoints.length - run.collapsedStart : 0;
|
||||
run.engineRawOffsets = Int32Array.from(engineRawOffsets);
|
||||
run.engineRawEnd = rawConcatOffset;
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
@@ -247,7 +287,50 @@ export class ChapterTextIndex {
|
||||
}
|
||||
|
||||
collapsedForRunOffset(run: TextRun, cpOffset: number): number {
|
||||
return run.collapsedStart + Math.max(0, Math.min(cpOffset, run.collapsedLength));
|
||||
const point = this.rawPointForEngineOffset(run, cpOffset);
|
||||
return point ? (this.collapsedFromNodePoint(point.node, point.offset) ?? run.collapsedStart) : run.collapsedStart;
|
||||
}
|
||||
|
||||
enginePointOfRawPoint(node: CfiNode, offset: number): { run: TextRun; offset: number } | null {
|
||||
const info = this.runInfoByNode.get(node);
|
||||
if (!info) return null;
|
||||
const target = info.rawStart + offset;
|
||||
const runs = this.engineRunsByRun.get(info.run) ?? [];
|
||||
const run = runs.find((candidate) => target < candidate.engineRawEnd) ?? runs.at(-1);
|
||||
if (!run) return null;
|
||||
let lo = 0;
|
||||
let hi = run.engineRawOffsets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (run.engineRawOffsets[mid] < target) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return { run, offset: lo };
|
||||
}
|
||||
|
||||
engineOffsetOfRawPoint(node: CfiNode, offset: number): number | null {
|
||||
const info = this.runInfoByNode.get(node);
|
||||
if (!info || !info.run.engineRawOffsets.length) return null;
|
||||
const target = info.rawStart + offset;
|
||||
const offsets = info.run.engineRawOffsets;
|
||||
let lo = 0;
|
||||
let hi = offsets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (offsets[mid] < target) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
rawPointForEngineOffset(run: TextRun, offset: number): RawPoint | null {
|
||||
if (!run.engineRawOffsets.length || offset < 0 || offset > run.engineRawOffsets.length) return null;
|
||||
let rawOffset = run.engineRawOffsets[offset] ?? run.engineRawEnd;
|
||||
for (const part of run.parts) {
|
||||
if (rawOffset < part.rawLength || part === run.parts[run.parts.length - 1]) return { node: part.node, offset: rawOffset };
|
||||
rawOffset -= part.rawLength;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Inclusive start point: resolves the collapsed cp index to (raw text node, UTF-16 offset). */
|
||||
@@ -319,7 +402,9 @@ export class ChapterTextIndex {
|
||||
|
||||
/** Run-relative crengine offset (code points) for a collapsed cp index. */
|
||||
runOffsetOfCollapsed(cpIndex: number, run: TextRun): number {
|
||||
return Math.max(0, Math.min(cpIndex, run.collapsedStart + run.collapsedLength) - run.collapsedStart);
|
||||
const index = Math.max(run.collapsedStart, Math.min(cpIndex, run.collapsedStart + run.collapsedLength));
|
||||
const point = index < run.collapsedStart + run.collapsedLength ? this.rawPointAt(index) : this.endPointFromCollapsed(index);
|
||||
return point ? (this.engineOffsetOfRawPoint(point.node, point.offset) ?? 0) : 0;
|
||||
}
|
||||
|
||||
runAtCollapsed(cpIndex: number, direction: 'forward' | 'backward' = 'forward'): TextRun | null {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ZipArchive } from 'archiver';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service';
|
||||
import { PositionConverterService } from './position-converter.service';
|
||||
|
||||
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
@@ -30,7 +31,7 @@ const CH2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head>
|
||||
<body><p>Second chapter text.</p></body></html>`;
|
||||
|
||||
async function buildEpub(path: string): Promise<void> {
|
||||
async function buildEpub(path: string, containerXml = CONTAINER_XML, contentOpf = CONTENT_OPF): Promise<void> {
|
||||
const archive = new ZipArchive({ zlib: { level: 0 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
@@ -39,8 +40,8 @@ async function buildEpub(path: string): Promise<void> {
|
||||
archive.on('error', reject);
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
|
||||
archive.append(CONTENT_OPF, { name: 'OEBPS/content.opf' });
|
||||
archive.append(containerXml, { name: 'META-INF/container.xml' });
|
||||
archive.append(contentOpf, { name: 'OEBPS/content.opf' });
|
||||
archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' });
|
||||
archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' });
|
||||
await archive.finalize();
|
||||
@@ -83,6 +84,22 @@ describe('EpubDomService', () => {
|
||||
expect(doc!.index.collapsed).toBe('Second chapter text.');
|
||||
});
|
||||
|
||||
it('reads prefixed container and OPF tags and converts a position in the correct spine chapter', async () => {
|
||||
const prefixedPath = join(dir, 'prefixed.epub');
|
||||
const container = CONTAINER_XML.replace(/(<\/?)(container|rootfiles|rootfile)(?=[\s>])/g, '$1ocf:$2').replace('xmlns=', 'xmlns:ocf=');
|
||||
const opf = CONTENT_OPF.replace(/(<\/?)(package|metadata|manifest|item|spine|itemref)(?=[\s/>])/g, '$1opf:$2').replace('xmlns=', 'xmlns:opf=');
|
||||
await buildEpub(prefixedPath, container, opf);
|
||||
const service = new EpubDomService(makeDb(prefixedPath) as never);
|
||||
|
||||
expect(await service.getSpineHrefs(1)).toEqual(['OEBPS/text/ch1.xhtml', 'OEBPS/text/ch2.xhtml']);
|
||||
expect((await service.getChapter(1, 1))?.index.collapsed).toBe('Second chapter text.');
|
||||
await expect(new PositionConverterService(service).cfiPointToXpointer({ bookFileId: 1, cfi: 'epubcfi(/6/4!/4/2/1:7)' })).resolves.toEqual({
|
||||
status: 'exact',
|
||||
pos0: '/body/DocFragment[2]/body/p/text().7',
|
||||
chapterIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves chapters by spine index through the service and caches them', async () => {
|
||||
const db = makeDb(epubPath);
|
||||
const service = new EpubDomService(db as never);
|
||||
|
||||
@@ -19,6 +19,7 @@ const SPINE_CACHE_MAX = 24;
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('parseChapterDocument', () => {
|
||||
|
||||
it('indexes named entities as the characters a renderer would show', () => {
|
||||
const doc = parseChapterDocument(chapter('<p>a b pa­sakyti c—d</p>'));
|
||||
expect(doc.index.collapsed).toBe('a b pasakyti c—d');
|
||||
expect(doc.index.collapsed).toBe('a b pasakyti c\u2014d');
|
||||
});
|
||||
|
||||
it('keeps escaped entity text literal instead of resolving it twice', () => {
|
||||
@@ -128,7 +128,7 @@ describe('xpointerRangeToCfi', () => {
|
||||
|
||||
it('maps collapsed crengine offsets back to raw offsets in indented source', () => {
|
||||
const doc = parseChapterDocument(INDENTED_CHAPTER);
|
||||
const result = xpointerRangeToCfi(doc, 0, '/body/DocFragment[1]/body/p/text().6', '/body/DocFragment[1]/body/p/text().11', 'world');
|
||||
const result = xpointerRangeToCfi(doc, 0, '/body/DocFragment[1]/body/p/text().7', '/body/DocFragment[1]/body/p/text().12', 'world');
|
||||
expect(result.status).toBe('exact');
|
||||
expect((result as { pos0: string }).pos0).toBe('epubcfi(/6/2!/4/2,/1:13,/1:18)');
|
||||
});
|
||||
@@ -216,8 +216,8 @@ describe('cfiRangeToXPointer', () => {
|
||||
const result = cfiRangeToXPointer(doc, 0, 'epubcfi(/6/2!/4/2,/1:13,/1:18)', 'world');
|
||||
expect(result).toEqual({
|
||||
status: 'exact',
|
||||
pos0: '/body/DocFragment[1]/body/p/text().6',
|
||||
pos1: '/body/DocFragment[1]/body/p/text().11',
|
||||
pos0: '/body/DocFragment[1]/body/p/text().7',
|
||||
pos1: '/body/DocFragment[1]/body/p/text().12',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,8 +285,8 @@ describe('round trips', () => {
|
||||
},
|
||||
{
|
||||
chapter: INDENTED_CHAPTER,
|
||||
pos0: '/body/DocFragment[1]/body/p/text().0',
|
||||
pos1: '/body/DocFragment[1]/body/p/text().5',
|
||||
pos0: '/body/DocFragment[1]/body/p/text().1',
|
||||
pos1: '/body/DocFragment[1]/body/p/text().6',
|
||||
text: 'Hello',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
spineCfiForChapterIndex,
|
||||
} from './cfi.utils';
|
||||
import { decodeNamedEntities } from './xhtml-entities';
|
||||
import { buildXPointer, parseXPointer, resolveXPointerElement } from './xpointer.utils';
|
||||
import { buildXPointer, buildXPointerPath, parseXPointer, resolveXPointerElement } from './xpointer.utils';
|
||||
|
||||
export const CONVERTER_VERSION = 1;
|
||||
export const CONVERTER_VERSION = 2;
|
||||
|
||||
const ENGINE_TEXT_SPLIT_SIZE = 8192;
|
||||
|
||||
/** KOReader sidecar text is truncated at 10000 chars; treat near-limit text as a prefix. */
|
||||
const TRUNCATED_TEXT_THRESHOLD = 9990;
|
||||
@@ -49,7 +51,42 @@ export type ConversionResult = ConversionSuccess | ConversionFailure;
|
||||
export function parseChapterDocument(xhtml: string): ChapterDocument {
|
||||
const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true });
|
||||
const root = $.root()[0] as unknown as CfiNode;
|
||||
return { root, index: new ChapterTextIndex(root) };
|
||||
if (xhtml.length < ENGINE_TEXT_SPLIT_SIZE) return { root, index: new ChapterTextIndex(root) };
|
||||
// CREngine splits source before entity decoding; decoded text lengths cannot determine its node indexes.
|
||||
const rawRoot = cheerio.load(xhtml, { xml: { decodeEntities: false } }).root()[0] as unknown as CfiNode;
|
||||
const boundaries = new Map<CfiNode, number[]>();
|
||||
const visit = (node: CfiNode, raw: CfiNode) => {
|
||||
if (isTextNode(node) && Array.from(raw.data ?? '').length >= ENGINE_TEXT_SPLIT_SIZE) {
|
||||
const chars = Array.from(raw.data ?? '');
|
||||
const offsets = [0];
|
||||
let consumed = 0;
|
||||
let decoded = '';
|
||||
while (consumed < chars.length) {
|
||||
let end = Math.min(consumed + ENGINE_TEXT_SPLIT_SIZE, chars.length);
|
||||
if (end - consumed === ENGINE_TEXT_SPLIT_SIZE) {
|
||||
for (let i = end - 1; i >= consumed; i -= 1) {
|
||||
if (chars[i] === ' ' || chars[i] === '\n' || chars[i] === '\r') {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const chunk = chars.slice(consumed, end).join('');
|
||||
decoded += cheerio
|
||||
.load(`<p>${decodeNamedEntities(chunk)}</p>`, { xml: true })('p')
|
||||
.text();
|
||||
offsets.push(decoded.length);
|
||||
consumed = end;
|
||||
}
|
||||
boundaries.set(node, decoded === node.data ? offsets : []);
|
||||
}
|
||||
for (let i = 0; i < (node.children?.length ?? 0); i += 1) {
|
||||
const rawChild = raw.children?.[i];
|
||||
if (rawChild) visit(node.children![i], rawChild);
|
||||
}
|
||||
};
|
||||
visit(root, rawRoot);
|
||||
return { root, index: new ChapterTextIndex(root, boundaries) };
|
||||
}
|
||||
|
||||
interface StructuralAnchor {
|
||||
@@ -70,7 +107,7 @@ function xpointerAnchor(doc: ChapterDocument, pos: string, exclusiveEnd: boolean
|
||||
if (parsed.offset == null) {
|
||||
return { cp: exclusiveEnd ? run.collapsedStart + run.collapsedLength : run.collapsedStart, resolved: false };
|
||||
}
|
||||
const withinRange = parsed.offset <= run.collapsedLength;
|
||||
const withinRange = parsed.offset <= run.engineRawOffsets.length;
|
||||
return { cp: doc.index.collapsedForRunOffset(run, parsed.offset), resolved: withinRange };
|
||||
}
|
||||
|
||||
@@ -159,7 +196,36 @@ export function collapsedPointToCfi(doc: ChapterDocument, chapterIndex: number,
|
||||
export function collapsedPointToXPointer(doc: ChapterDocument, chapterIndex: number, cp: number): string | null {
|
||||
const run = doc.index.runAtCollapsed(cp, 'forward') ?? doc.index.runAtCollapsed(cp, 'backward');
|
||||
if (!run) return null;
|
||||
return buildXPointer(run.parent, chapterIndex + 1, run.runIndex, run.runCount, doc.index.runOffsetOfCollapsed(cp, run));
|
||||
const point = doc.index.startPointFromCollapsed(cp);
|
||||
const engine = point ? doc.index.enginePointOfRawPoint(point.node, point.offset) : null;
|
||||
return engine ? buildXPointer(engine.run.parent, chapterIndex + 1, engine.run.runIndex, engine.run.runCount, engine.offset) : null;
|
||||
}
|
||||
|
||||
export function cfiPointToXPointer(doc: ChapterDocument, chapterIndex: number, cfi: string): string | null {
|
||||
const parsed = parseCfi(cfi);
|
||||
const point = parsed ? resolveCfiParts(doc.root, parsed.start) : null;
|
||||
if (!point?.node) return null;
|
||||
if (!isTextNode(point.node)) return buildXPointerPath(point.node, chapterIndex + 1);
|
||||
const engine = doc.index.enginePointOfRawPoint(point.node, point.offset ?? 0);
|
||||
return engine ? buildXPointer(engine.run.parent, chapterIndex + 1, engine.run.runIndex, engine.run.runCount, engine.offset) : null;
|
||||
}
|
||||
|
||||
export function xpointerPointToCfi(doc: ChapterDocument, chapterIndex: number, pos: string): string | null {
|
||||
const parsed = parseXPointer(pos);
|
||||
if (!parsed || parsed.docFragmentIndex !== chapterIndex + 1) return null;
|
||||
const element = resolveXPointerElement(doc.root, parsed.steps);
|
||||
if (!element) return null;
|
||||
if (!/\/text\(\)/.test(pos)) {
|
||||
if (parsed.offset != null && parsed.offset !== 0) return null;
|
||||
return joinCfiIndirection(spineCfiForChapterIndex(chapterIndex), cfiFromPoint({ node: element, offset: 0 }));
|
||||
}
|
||||
if (parsed.textIndex == null || parsed.offset == null) {
|
||||
const cp = xpointerPointToCollapsed(doc, pos);
|
||||
return cp == null ? null : collapsedPointToCfi(doc, chapterIndex, cp);
|
||||
}
|
||||
const run = doc.index.runsOfParent(element)[parsed.textIndex - 1];
|
||||
const point = run ? doc.index.rawPointForEngineOffset(run, parsed.offset) : null;
|
||||
return point ? joinCfiIndirection(spineCfiForChapterIndex(chapterIndex), cfiFromPoint(point)) : null;
|
||||
}
|
||||
|
||||
/** Resolves a point xpointer to its collapsed cp index (null when structure is unresolvable). */
|
||||
@@ -235,15 +301,14 @@ export function collapsedRangeToXPointerPair(
|
||||
const endRun = doc.index.runAtCollapsed(endCp - 1, 'backward');
|
||||
if (!startRun || !endRun) return null;
|
||||
|
||||
const start = doc.index.startPointFromCollapsed(startCp);
|
||||
const end = doc.index.endPointFromCollapsed(endCp);
|
||||
const engineStart = start ? doc.index.enginePointOfRawPoint(start.node, start.offset) : null;
|
||||
const engineEnd = end ? doc.index.enginePointOfRawPoint(end.node, end.offset) : null;
|
||||
if (!engineStart || !engineEnd) return null;
|
||||
const docFragmentIndex = chapterIndex + 1;
|
||||
const pos0 = buildXPointer(
|
||||
startRun.parent,
|
||||
docFragmentIndex,
|
||||
startRun.runIndex,
|
||||
startRun.runCount,
|
||||
doc.index.runOffsetOfCollapsed(startCp, startRun),
|
||||
);
|
||||
const pos1 = buildXPointer(endRun.parent, docFragmentIndex, endRun.runIndex, endRun.runCount, doc.index.runOffsetOfCollapsed(endCp, endRun));
|
||||
const pos0 = buildXPointer(engineStart.run.parent, docFragmentIndex, engineStart.run.runIndex, engineStart.run.runCount, engineStart.offset);
|
||||
const pos1 = buildXPointer(engineEnd.run.parent, docFragmentIndex, engineEnd.run.runIndex, engineEnd.run.runCount, engineEnd.offset);
|
||||
if (!pos0 || !pos1) return null;
|
||||
return { pos0, pos1 };
|
||||
}
|
||||
|
||||
@@ -98,3 +98,133 @@ describe('PositionConverterService cross-edition read-aloud positions', () => {
|
||||
).resolves.toEqual({ status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PositionConverterService reading progress points', () => {
|
||||
const chapter = (body: string) => parseChapterDocument(`<html><head><title>Position</title></head><body>${body}</body></html>`);
|
||||
|
||||
it.each([
|
||||
{
|
||||
body: '<p>First paragraph.</p><p>Second paragraph.</p>',
|
||||
cfi: 'epubcfi(/6/2!/4/4/1:7)',
|
||||
pos: '/body/DocFragment[1]/body/p[2]/text().7',
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
body: '<p>First paragraph.</p><p>Second paragraph.</p>',
|
||||
cfi: 'epubcfi(/6/8!/4/4/1:7)',
|
||||
pos: '/body/DocFragment[4]/body/p[2]/text().7',
|
||||
index: 3,
|
||||
},
|
||||
{ body: '<p>First paragraph.</p>', cfi: 'epubcfi(/6/2!/4/2/1:0)', pos: '/body/DocFragment[1]/body/p/text().0', index: 0 },
|
||||
{ body: '<p>one <em>two</em> three</p>', cfi: 'epubcfi(/6/2!/4/2/2/1:1)', pos: '/body/DocFragment[1]/body/p/em/text().1', index: 0 },
|
||||
{ body: '<p>one <em>two</em> three</p>', cfi: 'epubcfi(/6/2!/4/2/3:2)', pos: '/body/DocFragment[1]/body/p/text()[2].2', index: 0 },
|
||||
{ body: '<p>ab\u{1F600}cd efgh</p>', cfi: 'epubcfi(/6/2!/4/2/1:4)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
|
||||
{ body: '<p>\n Hello world\n </p>', cfi: 'epubcfi(/6/2!/4/2/1:13)', pos: '/body/DocFragment[1]/body/p/text().7', index: 0 },
|
||||
{ body: '<p> Leading text.</p>', cfi: 'epubcfi(/6/2!/4/2/1:5)', pos: '/body/DocFragment[1]/body/p/text().5', index: 0 },
|
||||
{ body: '<p>\n Leading text.</p>', cfi: 'epubcfi(/6/2!/4/2/1:9)', pos: '/body/DocFragment[1]/body/p/text().5', index: 0 },
|
||||
{ body: '<p>a\u00a0\u00a0bc</p>', cfi: 'epubcfi(/6/2!/4/2/1:3)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
|
||||
{ body: '<p>a\u2003\u2003bc</p>', cfi: 'epubcfi(/6/2!/4/2/1:3)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
|
||||
{ body: '<p>ab\u{1F600}</p>', cfi: 'epubcfi(/6/2!/4/2/1:4)', pos: '/body/DocFragment[1]/body/p/text().3', index: 0 },
|
||||
{ body: '<div><img src="page.png"/></div>', cfi: 'epubcfi(/6/2!/4/2/2)', pos: '/body/DocFragment[1]/body/div/img', index: 0 },
|
||||
{ body: '<p>Text</p>', cfi: 'epubcfi(/6/2!/4/2)', pos: '/body/DocFragment[1]/body/p', index: 0 },
|
||||
{
|
||||
body: '<div>\n<p>First</p>\n<p>Second</p>\nThird<br/> fourth</div>',
|
||||
cfi: 'epubcfi(/6/2!/4/2/7:3)',
|
||||
pos: '/body/DocFragment[1]/body/div/text()[2].3',
|
||||
index: 0,
|
||||
},
|
||||
])('preserves the text point in $cfi and round trips it', async ({ body, cfi, pos, index }) => {
|
||||
const { service, epubDom } = makeService({ 10: chapter(body) });
|
||||
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi })).resolves.toEqual({ status: 'exact', pos0: pos, chapterIndex: index });
|
||||
expect(epubDom.getChapter).toHaveBeenCalledWith(10, index);
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos })).resolves.toEqual({ status: 'exact', cfi, chapterIndex: index });
|
||||
});
|
||||
|
||||
it('converts the explicit singleton indexes emitted after CREngine page turns', async () => {
|
||||
const { service } = makeService({ 10: chapter('<div><p>First paragraph.</p><p>Second paragraph.</p></div>') });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body[1]/DocFragment[1]/body[1]/div[1]/p[2]/text()[1].7' })).resolves.toEqual({
|
||||
status: 'exact',
|
||||
cfi: 'epubcfi(/6/2!/4/2/4/1:7)',
|
||||
chapterIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid root body index', async () => {
|
||||
const { service } = makeService({ 10: chapter('<p>Text</p>') });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body[2]/DocFragment[1]/body/p/text().1' })).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: 'word '.repeat(2000), rawOffset: 9000, engineOffset: 810 },
|
||||
{ source: 'word& '.repeat(1100), rawOffset: 6300, engineOffset: 1386 },
|
||||
{ source: 'a'.repeat(8200), rawOffset: 8195, engineOffset: 3 },
|
||||
{ source: 'word '.repeat(4000), rawOffset: 17000, engineOffset: 620 },
|
||||
])('maps source-parser text chunks in long paragraphs at $rawOffset', async ({ source, rawOffset, engineOffset }) => {
|
||||
const { service } = makeService({ 10: chapter(`<p>${source}</p>`) });
|
||||
const converted = await service.cfiPointToXpointer({ bookFileId: 10, cfi: `epubcfi(/6/2!/4/2/1:${rawOffset})` });
|
||||
const textIndex = rawOffset === 17000 ? 3 : 2;
|
||||
expect(converted).toEqual({ status: 'exact', pos0: `/body/DocFragment[1]/body/p/text()[${textIndex}].${engineOffset}`, chapterIndex: 0 });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: converted.status === 'failed' ? '' : converted.pos0! })).resolves.toEqual({
|
||||
status: 'exact',
|
||||
cfi: `epubcfi(/6/2!/4/2/1:${rawOffset})`,
|
||||
chapterIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves paragraph 40 in the issue 1621 reproduction instead of the chapter start', async () => {
|
||||
const body = '<h1>Chapter One</h1>' + Array.from({ length: 100 }, (_, i) => `<p>${`Paragraph ${i + 1}. `.repeat(20)}</p>`).join('');
|
||||
const { service } = makeService({ 10: chapter(body) });
|
||||
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi: 'epubcfi(/6/2!/4/82/1:50)' })).resolves.toEqual({
|
||||
status: 'exact',
|
||||
pos0: '/body/DocFragment[1]/body/p[40]/text().50',
|
||||
chapterIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a text-node index removed by CREngine instead of choosing a different node', async () => {
|
||||
const { service } = makeService({ 10: chapter('<div>\n<p>First</p>\nThird</div>') });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/div/text()[2].1' })).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an offset beyond the engine text node', async () => {
|
||||
const { service } = makeService({ 10: chapter('<p>Text</p>') });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/p/text().5' })).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts the zero-offset element pointer CREngine emits for an image', async () => {
|
||||
const { service } = makeService({ 10: chapter('<div><img src="page.png"/></div>') });
|
||||
await expect(service.xpointerPointToCfi({ bookFileId: 10, pos: '/body/DocFragment[1]/body/div/img.0' })).resolves.toEqual({
|
||||
status: 'exact',
|
||||
cfi: 'epubcfi(/6/2!/4/2/2)',
|
||||
chapterIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ cfi: 'tts:0:40', reason: 'missing_spine_step' },
|
||||
{ cfi: 'epubcfi(/4/2/1:3)', reason: 'missing_spine_step' },
|
||||
{ cfi: 'epubcfi(/6/2!/4/999/1:3)', reason: 'unresolvable_structure', chapterIndex: 0 },
|
||||
])('fails explicitly for $cfi', async ({ cfi, reason, chapterIndex }) => {
|
||||
const { service } = makeService({ 10: chapter('<p>Text</p>') });
|
||||
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi })).resolves.toEqual({
|
||||
status: 'failed',
|
||||
reason,
|
||||
...(chapterIndex === undefined ? {} : { chapterIndex }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not manufacture a position when the requested chapter is unavailable', async () => {
|
||||
const { service } = makeService({});
|
||||
await expect(service.cfiPointToXpointer({ bookFileId: 10, cfi: 'epubcfi(/6/8!/4/2/1:3)' })).resolves.toEqual({
|
||||
status: 'failed',
|
||||
reason: 'chapter_unavailable',
|
||||
chapterIndex: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
ConversionResult,
|
||||
CONVERTER_VERSION,
|
||||
cfiPointToCollapsedCp,
|
||||
cfiPointToXPointer,
|
||||
cfiRangeToXPointer,
|
||||
collapsedPointToCfi,
|
||||
collapsedPointToXPointer,
|
||||
xpointerPointToCollapsed,
|
||||
xpointerPointToCfi,
|
||||
xpointerRangeToCfi,
|
||||
} from './position-converter.core';
|
||||
import { parseXPointer } from './xpointer.utils';
|
||||
@@ -89,10 +91,8 @@ export class PositionConverterService {
|
||||
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
|
||||
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
|
||||
|
||||
const cp = xpointerPointToCollapsed(doc, params.pos);
|
||||
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
|
||||
const cfi = collapsedPointToCfi(doc, chapterIndex, cp);
|
||||
if (!cfi) return { status: 'failed', reason: 'cfi_generation_failed', chapterIndex };
|
||||
const cfi = xpointerPointToCfi(doc, chapterIndex, params.pos);
|
||||
if (!cfi) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
|
||||
return { status: 'exact', cfi, chapterIndex };
|
||||
}
|
||||
|
||||
@@ -109,10 +109,8 @@ export class PositionConverterService {
|
||||
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
|
||||
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
|
||||
|
||||
const cp = cfiPointToCollapsedCp(doc, params.cfi);
|
||||
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
|
||||
const pos = collapsedPointToXPointer(doc, chapterIndex, cp);
|
||||
if (!pos) return { status: 'failed', reason: 'xpointer_generation_failed', chapterIndex };
|
||||
const pos = cfiPointToXPointer(doc, chapterIndex, params.cfi);
|
||||
if (!pos) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
|
||||
return { status: 'exact', pos0: pos, chapterIndex };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ParsedXPointer {
|
||||
offset: number | null;
|
||||
}
|
||||
|
||||
const XPOINTER_RE = /^\/body\/DocFragment(?:\[(\d+)\])?(.*)$/;
|
||||
const XPOINTER_RE = /^\/body(?:\[1\])?\/DocFragment(?:\[(\d+)\])?(.*)$/;
|
||||
const STEP_RE = /^([A-Za-z][\w:-]*)(?:\[(\d+)\])?$/;
|
||||
const TEXT_STEP_RE = /^text\(\)(?:\[(\d+)\])?$/;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user