fix(kobo): sync native kepub progress and canonical positions

Allow native KEPUB files to exchange precise reading progress, bookmarks
and highlights with Kobo while preserving sync permissions and settings.

Use the original KEPUB archive for Kobo spans and project its added
wrappers away for shared EPUB CFI and KOReader XPointer positions.
Preserve existing EPUB conversion behavior and API compatibility with
released iOS clients.

Cover native KEPUB context, canonical positions and two-way exchange with
unit tests and paired EPUB/KEPUB API E2E scenarios.

Ref bookorbit/bookorbit-ios-support#36
This commit is contained in:
neon
2026-10-06 20:53:08 +00:00
parent 2dddf9b89f
commit 1abf2f58ca
13 changed files with 199 additions and 28 deletions
@@ -1262,12 +1262,12 @@ describe('BookRepository', () => {
); );
}); });
it('syncs primary EPUB progress into Kobo reading state and marks snapshot row pending', async () => { it.each(['epub', 'kepub'])('syncs primary %s progress into Kobo reading state and marks snapshot row pending', async (format) => {
const insertChain = makeInsertChain(); const insertChain = makeInsertChain();
const db = { const db = {
select: vi select: vi
.fn() .fn()
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }])) .mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format, markAsFinishedPercentComplete: 98 }]))
.mockReturnValueOnce( .mockReturnValueOnce(
makeSelectChain('limit', [ makeSelectChain('limit', [
{ {
+1 -1
View File
@@ -2512,7 +2512,7 @@ export class BookRepository {
.where(eq(bookFiles.id, fileId)) .where(eq(bookFiles.id, fileId))
.limit(1); .limit(1);
if (!file || file.primaryFileId !== fileId || file.format !== 'epub') return false; if (!file || file.primaryFileId !== fileId || (file.format !== 'epub' && file.format !== 'kepub')) return false;
const clampedPercentage = this.clampProgressPercentage(percentage); const clampedPercentage = this.clampProgressPercentage(percentage);
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource); const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
+6 -6
View File
@@ -3070,11 +3070,11 @@ describe('BookService', () => {
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it('mirrors EPUB percentage to Kobo state for users with Kobo sync permission', async () => { it.each(['epub', 'kepub'])('mirrors %s percentage to Kobo state for users with Kobo sync permission', async (format) => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService(); const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] }); const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' }); bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format });
bookRepo.upsertProgress.mockResolvedValue(undefined); bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(true); bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(true);
bookRepo.syncKoboReadingStateFromProgress.mockResolvedValue(true); bookRepo.syncKoboReadingStateFromProgress.mockResolvedValue(true);
@@ -3103,11 +3103,11 @@ describe('BookService', () => {
}); });
}); });
it('does not mirror EPUB percentage to Kobo state when two-way sync is disabled', async () => { it.each(['epub', 'kepub'])('does not mirror %s percentage to Kobo state when two-way sync is disabled', async (format) => {
const { service, bookRepo, libraryService } = makeService(); const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] }); const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' }); bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format });
bookRepo.upsertProgress.mockResolvedValue(undefined); bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false); bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false);
libraryService.verifyUserAccess.mockResolvedValue(undefined); libraryService.verifyUserAccess.mockResolvedValue(undefined);
@@ -3206,11 +3206,11 @@ describe('BookService', () => {
}); });
}); });
it('does not mirror EPUB percentage to Kobo state without Kobo sync permission', async () => { it.each(['epub', 'kepub'])('does not mirror %s percentage to Kobo state without Kobo sync permission', async (format) => {
const { service, bookRepo, libraryService } = makeService(); const { service, bookRepo, libraryService } = makeService();
const user = makeUser(); const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' }); bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format });
bookRepo.upsertProgress.mockResolvedValue(undefined); bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined); libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 }); libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
+5 -1
View File
@@ -2276,7 +2276,11 @@ export class BookService {
// Everything downstream reads the position the file now holds, not the one the client sent. // Everything downstream reads the position the file now holds, not the one the client sent.
// A narration write that did not move the text position must not move a Kobo bookmark, an // A narration write that did not move the text position must not move a Kobo bookmark, an
// audiobook position, or a read status either. // audiobook position, or a read status either.
if (file.format === 'epub' && this.hasPermission(user, Permission.KoboSync) && (await this.bookRepo.isKoboTwoWayProgressSyncEnabled(userId))) { if (
(file.format === 'epub' || file.format === 'kepub') &&
this.hasPermission(user, Permission.KoboSync) &&
(await this.bookRepo.isKoboTwoWayProgressSyncEnabled(userId))
) {
await this.bookRepo.syncKoboReadingStateFromProgress( await this.bookRepo.syncKoboReadingStateFromProgress(
userId, userId,
fileId, fileId,
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { KoboKepubContextService } from './kobo-kepub-context.service';
function makeContext(format: string, convertToKepub = true) {
const file = { id: 12, bookId: 3, absolutePath: '/library/book.kepub', format, fileHash: 'hash', sizeBytes: 200 };
const reader = { findPrimaryReaderFilesByBookIds: vi.fn().mockResolvedValue([file]) };
const settings = { convertToKepub, forceEnableHyphenation: true, kepubConversionLimitMb: 0 };
const conversion = { getKepubPath: vi.fn().mockResolvedValue('/library/converted.kepub') };
const binary = { getVersion: vi.fn().mockResolvedValue('test-version') };
const service = new KoboKepubContextService(
reader as never,
{ getSettings: vi.fn().mockResolvedValue(settings) } as never,
conversion as never,
binary as never,
);
return { service, file, settings, conversion, binary };
}
describe('KoboKepubContextService', () => {
it.each([true, false])('uses the original native KEPUB with conversion enabled=%s', async (convert) => {
const { service, file, settings, conversion, binary } = makeContext('kepub', convert);
expect(await service.resolveForBook(7, 3)).toEqual({
ok: true,
file,
settings,
ctx: { kepubPath: file.absolutePath, fileHash: 'hash', hyphenate: false, kepubifyVersion: 'native-kepub' },
});
expect(conversion.getKepubPath).not.toHaveBeenCalled();
expect(binary.getVersion).not.toHaveBeenCalled();
});
it('preserves EPUB conversion and hyphenation settings', async () => {
const { service, settings, file, conversion } = makeContext('epub');
settings.kepubConversionLimitMb = 100;
expect(await service.resolveForBook(7, 3)).toMatchObject({
ok: true,
ctx: { kepubPath: '/library/converted.kepub', hyphenate: true, kepubifyVersion: 'test-version' },
});
expect(conversion.getKepubPath).toHaveBeenCalledWith({ sourcePath: file.absolutePath, fileHash: 'hash', bookId: 3, hyphenate: true });
});
it('keeps unsupported formats and disabled EPUB conversion degraded', async () => {
expect(await makeContext('pdf').service.resolveForBook(7, 3)).toMatchObject({ ok: false, reason: 'kepub_required' });
expect(await makeContext('epub', false).service.resolveForBook(7, 3)).toMatchObject({ ok: false, reason: 'kepub_required' });
});
});
@@ -43,6 +43,14 @@ export class KoboKepubContextService {
if (!file) return { ok: false, reason: 'no_reader_file', settings: null }; if (!file) return { ok: false, reason: 'no_reader_file', settings: null };
const settings = await this.koboSettingsService.getSettings(userId); const settings = await this.koboSettingsService.getSettings(userId);
if (file.format === 'kepub') {
return {
ok: true,
file,
settings,
ctx: { kepubPath: file.absolutePath, fileHash: file.fileHash, hyphenate: false, kepubifyVersion: 'native-kepub' },
};
}
const readiness = this.getKepubReadinessFailure(file, settings); const readiness = this.getKepubReadinessFailure(file, settings);
if (readiness) return { ok: false, reason: readiness, settings }; if (readiness) return { ok: false, reason: readiness, settings };
@@ -557,6 +557,15 @@ describe('KoboReadingStateService', () => {
return { db, updateChain }; return { db, updateChain };
} }
it('includes native KEPUB in the scoped primary-file lookup for hub bookmark delivery', async () => {
const { db } = makeRefreshableDb();
const capture = createCapturingDb();
db.select = capture.db.select.bind(capture.db) as never;
await makeService(db).getRawState(1, 44);
expect(capture.queries).toHaveLength(1);
expect(capture.queries[0]!.params).toEqual([44, 'epub', 'kepub', 1]);
});
it('replaces the device Location with one computed from the hub position', async () => { it('replaces the device Location with one computed from the hub position', async () => {
const { db, updateChain } = makeRefreshableDb(); const { db, updateChain } = makeRefreshableDb();
@@ -404,7 +404,7 @@ export class KoboReadingStateService {
.select({ fileId: schema.bookFiles.id }) .select({ fileId: schema.bookFiles.id })
.from(schema.books) .from(schema.books)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId)) .innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.where(and(eq(schema.books.id, bookId), eq(schema.bookFiles.format, 'epub'))) .where(and(eq(schema.books.id, bookId), inArray(schema.bookFiles.format, ['epub', 'kepub'])))
.limit(1); .limit(1);
if (!primaryFile) return null; if (!primaryFile) return null;
@@ -5,6 +5,7 @@ import * as unzipper from 'unzipper';
import { ZipArchive } from 'archiver'; import { ZipArchive } from 'archiver';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { collapsedPointToCfi } from './position-converter.core';
import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service'; import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.service';
import { PositionConverterService } from './position-converter.service'; import { PositionConverterService } from './position-converter.service';
@@ -31,7 +32,10 @@ const CH2 = `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head> <html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head>
<body><p>Second chapter text.</p></body></html>`; <body><p>Second chapter text.</p></body></html>`;
async function buildEpub(path: string, containerXml = CONTAINER_XML, contentOpf = CONTENT_OPF): Promise<void> { async function buildEpub(
path: string,
{ chapter = CH1, containerXml = CONTAINER_XML, contentOpf = CONTENT_OPF }: { chapter?: string; containerXml?: string; contentOpf?: string } = {},
): Promise<void> {
const archive = new ZipArchive({ zlib: { level: 0 } }); const archive = new ZipArchive({ zlib: { level: 0 } });
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk)); archive.on('data', (chunk: Buffer) => chunks.push(chunk));
@@ -42,7 +46,7 @@ async function buildEpub(path: string, containerXml = CONTAINER_XML, contentOpf
archive.append('application/epub+zip', { name: 'mimetype', store: true }); archive.append('application/epub+zip', { name: 'mimetype', store: true });
archive.append(containerXml, { name: 'META-INF/container.xml' }); archive.append(containerXml, { name: 'META-INF/container.xml' });
archive.append(contentOpf, { name: 'OEBPS/content.opf' }); archive.append(contentOpf, { name: 'OEBPS/content.opf' });
archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' }); archive.append(chapter, { name: 'OEBPS/text/ch1.xhtml' });
archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' }); archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' });
await archive.finalize(); await archive.finalize();
await done; await done;
@@ -88,7 +92,7 @@ describe('EpubDomService', () => {
const prefixedPath = join(dir, 'prefixed.epub'); const prefixedPath = join(dir, 'prefixed.epub');
const container = CONTAINER_XML.replace(/(<\/?)(container|rootfiles|rootfile)(?=[\s>])/g, '$1ocf:$2').replace('xmlns=', 'xmlns:ocf='); 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='); const opf = CONTENT_OPF.replace(/(<\/?)(package|metadata|manifest|item|spine|itemref)(?=[\s/>])/g, '$1opf:$2').replace('xmlns=', 'xmlns:opf=');
await buildEpub(prefixedPath, container, opf); await buildEpub(prefixedPath, { containerXml: container, contentOpf: opf });
const service = new EpubDomService(makeDb(prefixedPath) as never); 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.getSpineHrefs(1)).toEqual(['OEBPS/text/ch1.xhtml', 'OEBPS/text/ch2.xhtml']);
@@ -112,6 +116,20 @@ describe('EpubDomService', () => {
expect(await service.getChapter(1, 9)).toBeNull(); expect(await service.getChapter(1, 9)).toBeNull();
}); });
it('canonicalizes native KEPUB wrappers and accumulates offsets across spans', async () => {
const nativePath = join(dir, 'native.kepub');
await buildEpub(nativePath, {
chapter:
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>T</title></head><body><div id="book-columns"><div id="book-inner"><p><span class="koboSpan" id="kobo.1.1">Alpha sentence. </span><span class="koboSpan" id="kobo.1.2">Second sentence.</span></p></div></div></body></html>',
});
const service = new EpubDomService(makeDb(nativePath, 'kepub') as never);
const doc = (await service.getChapter(2, 0))!;
expect(doc.index.collapsed).toBe('Alpha sentence. Second sentence.');
expect(collapsedPointToCfi(doc, 0, 22)).toBe('epubcfi(/6/2!/4/2/1:22)');
const raw = await loadChapterFromZip(await unzipper.Open.file(nativePath), 'OEBPS/text/ch1.xhtml');
expect(collapsedPointToCfi(raw!, 0, 22)).toContain('[kobo.1.2]');
});
it('returns null for non-epub files', async () => { it('returns null for non-epub files', async () => {
const db = makeDb(epubPath, 'pdf'); const db = makeDb(epubPath, 'pdf');
const service = new EpubDomService(db as never); const service = new EpubDomService(db as never);
@@ -100,15 +100,16 @@ export async function readEpubSpine(zip: unzipper.CentralDirectory): Promise<Epu
return { hrefs }; return { hrefs };
} }
export async function loadChapterFromZip(zip: unzipper.CentralDirectory, href: string): Promise<ChapterDocument | null> { export async function loadChapterFromZip(zip: unzipper.CentralDirectory, href: string, canonicalKepub = false): Promise<ChapterDocument | null> {
const entry = findInZip(zip.files, href); const entry = findInZip(zip.files, href);
if (!entry) return null; if (!entry) return null;
const xhtml = (await entry.buffer()).toString('utf-8'); const xhtml = (await entry.buffer()).toString('utf-8');
return parseChapterDocument(xhtml); return parseChapterDocument(xhtml, canonicalKepub);
} }
interface SpineCacheEntry { interface SpineCacheEntry {
absolutePath: string; absolutePath: string;
format: string;
mtimeMs: number; mtimeMs: number;
spine: EpubSpine; spine: EpubSpine;
} }
@@ -137,7 +138,7 @@ export class EpubDomService {
const href = entry.spine.hrefs[chapterIndex]; const href = entry.spine.hrefs[chapterIndex];
if (href == null) return null; if (href == null) return null;
const cacheKey = `${bookFileId}:${entry.mtimeMs}:${chapterIndex}`; const cacheKey = `${bookFileId}:${entry.format}:${entry.mtimeMs}:${chapterIndex}`;
const cached = this.chapterCache.get(cacheKey); const cached = this.chapterCache.get(cacheKey);
if (cached) { if (cached) {
this.chapterCache.delete(cacheKey); this.chapterCache.delete(cacheKey);
@@ -147,7 +148,7 @@ export class EpubDomService {
try { try {
const zip = await unzipper.Open.file(entry.absolutePath); const zip = await unzipper.Open.file(entry.absolutePath);
const doc = await loadChapterFromZip(zip, href); const doc = await loadChapterFromZip(zip, href, entry.format === 'kepub');
if (!doc) return null; if (!doc) return null;
this.chapterCache.set(cacheKey, doc); this.chapterCache.set(cacheKey, doc);
while (this.chapterCache.size > CHAPTER_CACHE_MAX) { while (this.chapterCache.size > CHAPTER_CACHE_MAX) {
@@ -167,7 +168,7 @@ export class EpubDomService {
.from(bookFiles) .from(bookFiles)
.where(eq(bookFiles.id, bookFileId)) .where(eq(bookFiles.id, bookFileId))
.limit(1); .limit(1);
if (!file || file.format !== 'epub') return null; if (!file || (file.format !== 'epub' && file.format !== 'kepub')) return null;
let mtimeMs: number; let mtimeMs: number;
try { try {
@@ -178,12 +179,12 @@ export class EpubDomService {
} }
const cached = this.spineCache.get(bookFileId); const cached = this.spineCache.get(bookFileId);
if (cached && cached.absolutePath === file.absolutePath && cached.mtimeMs === mtimeMs) return cached; if (cached && cached.absolutePath === file.absolutePath && cached.format === file.format && cached.mtimeMs === mtimeMs) return cached;
try { try {
const zip = await unzipper.Open.file(file.absolutePath); const zip = await unzipper.Open.file(file.absolutePath);
const spine = await readEpubSpine(zip); const spine = await readEpubSpine(zip);
const entry: SpineCacheEntry = { absolutePath: file.absolutePath, mtimeMs, spine }; const entry: SpineCacheEntry = { absolutePath: file.absolutePath, format: file.format, mtimeMs, spine };
this.spineCache.set(bookFileId, entry); this.spineCache.set(bookFileId, entry);
while (this.spineCache.size > SPINE_CACHE_MAX) { while (this.spineCache.size > SPINE_CACHE_MAX) {
const oldest = this.spineCache.keys().next().value as number; const oldest = this.spineCache.keys().next().value as number;
@@ -77,8 +77,8 @@ async function buildEpub(path: string): Promise<void> {
await writeFile(path, Buffer.concat(chunks)); await writeFile(path, Buffer.concat(chunks));
} }
function makeDb(absolutePath: string) { function makeDb(absolutePath: string, format = 'epub') {
const limit = vi.fn().mockResolvedValue([{ absolutePath, format: 'epub' }]); const limit = vi.fn().mockResolvedValue([{ absolutePath, format }]);
const where = vi.fn().mockReturnValue({ limit }); const where = vi.fn().mockReturnValue({ limit });
const from = vi.fn().mockReturnValue({ where }); const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from }); const select = vi.fn().mockReturnValue({ from });
@@ -115,6 +115,50 @@ describe('KoboSpanConverterService (real kepubify output)', () => {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
}); });
it('round-trips native KEPUB bookmarks and highlights through canonical EPUB positions', async () => {
const nativeDb = makeDb(kepubPath, 'kepub');
const nativeDom = new EpubDomService(nativeDb as never);
const nativeConverter = new KoboSpanConverterService(nativeDom, kepubDom);
const nativeContext = { ...ctx, kepubifyVersion: 'native-kepub' };
const originalDoc = (await epubDom.getChapter(1, 0))!;
const nativeCanonicalDoc = (await nativeDom.getChapter(2, 0))!;
expect(nativeCanonicalDoc.index.collapsed).toBe(originalDoc.index.collapsed);
const raw = (await kepubDom.getChapterByIndex(kepubPath, 0))!;
const span = buildKoboSpanIndex(raw).ordered.find((entry) =>
raw.index.extractCollapsed(entry.collapsedStart, entry.collapsedEnd).includes('clocks'),
)!;
const original = await service.koboBookmarkToPositions({ bookFileId: 1, ctx, chapterFilename: 'OEBPS/text/ch1.xhtml', spanId: span.id });
const native = await nativeConverter.koboBookmarkToPositions({
bookFileId: 2,
ctx: nativeContext,
chapterFilename: 'OEBPS/text/ch1.xhtml',
spanId: span.id,
});
expect(native.status).toBe('exact');
expect(native.cfi).toBe(original.cfi);
expect(native.xpointer).toBe(original.xpointer);
const bookmark = await nativeConverter.cfiPointToKoboBookmark({ bookFileId: 2, ctx: nativeContext, cfi: native.cfi! });
expect(bookmark).toMatchObject({ status: 'exact', spanId: span.id, chapterFilename: 'OEBPS/text/ch1.xhtml' });
const highlight = await nativeConverter.koboSpanToCanonical({
bookFileId: 2,
ctx: nativeContext,
location: {
span: {
startPath: spanSelectorFromId(span.id),
startChar: 0,
endPath: spanSelectorFromId(span.id),
endChar: 10,
chapterFilename: 'OEBPS/text/ch1.xhtml',
},
},
text: null,
});
expect(highlight.status).toBe('exact');
expect(highlight.cfi).not.toContain('kobo.');
const restored = await nativeConverter.canonicalToKoboSpan({ bookFileId: 2, ctx: nativeContext, cfi: highlight.cfi!, text: null });
expect(restored).toMatchObject({ status: 'exact', pos0: `${span.id}:0`, pos1: `${span.id}:10` });
});
it('produces collapse-identical chapter text for every chapter (R1 gate)', async () => { it('produces collapse-identical chapter text for every chapter (R1 gate)', async () => {
for (let chapterIndex = 0; chapterIndex < 2; chapterIndex += 1) { for (let chapterIndex = 0; chapterIndex < 2; chapterIndex += 1) {
const epubDoc = await epubDom.getChapter(1, chapterIndex); const epubDoc = await epubDom.getChapter(1, chapterIndex);
@@ -48,8 +48,14 @@ export interface ConversionFailure {
export type ConversionResult = ConversionSuccess | ConversionFailure; export type ConversionResult = ConversionSuccess | ConversionFailure;
export function parseChapterDocument(xhtml: string): ChapterDocument { export function parseChapterDocument(xhtml: string, canonicalKepub = false): ChapterDocument {
const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true }); const $ = cheerio.load(decodeNamedEntities(xhtml), { xml: true });
if (canonicalKepub) {
// Match the iOS CFI projection, retaining text order and semantic markup.
$('span.koboSpan, div#book-columns, div#book-inner').each((_index, wrapper) => {
$(wrapper).replaceWith($(wrapper).contents());
});
}
const root = $.root()[0] as unknown as CfiNode; const root = $.root()[0] as unknown as CfiNode;
if (xhtml.length < ENGINE_TEXT_SPLIT_SIZE) 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. // CREngine splits source before entity decoding; decoded text lengths cannot determine its node indexes.
+38 -4
View File
@@ -1,7 +1,14 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { rename, rm } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema'; import * as schema from '../src/db/schema';
import { KoboKepubContextService } from '../src/modules/kobo/services/kobo-kepub-context.service';
import { KoboSpanConverterService } from '../src/modules/position-converter/kobo-span-converter.service';
import { KepubifyBinaryService } from '../src/modules/kobo/services/kepubify-binary.service';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder'; import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import { import {
authHeader, authHeader,
@@ -39,7 +46,7 @@ function flushTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 150)); return new Promise((resolve) => setTimeout(resolve, 150));
} }
describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => { describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout: 180_000 }, (format) => {
let ctx!: ReaderStateIsolationE2EContext; let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary; let library!: CreatedLibrary;
let epub!: LocatedBookFile; let epub!: LocatedBookFile;
@@ -95,12 +102,26 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
beforeAll(async () => { beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext(); ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `kobo-annotations-${randomUUID()}` }); library = await createLibraryWithFolder(ctx, { name: `kobo-annotations-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'kobo-sync-book.epub', { let epubPath = await createEpubFixture(library.folderPath, 'kobo-sync-book.epub', {
title: `Kobo Sync Book ${randomUUID()}`, title: `Kobo Sync Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`, uid: `urn:uuid:${randomUUID()}`,
}); });
if (format === 'kepub') {
const nativePath = join(library.folderPath, 'kobo-sync-book.kepub');
const binary = ctx.app.get(KepubifyBinaryService);
await promisify(execFile)(await binary.getBinaryPath(), ['--no-add-dummy-titlepage', '--output', `${nativePath}.epub`, epubPath], {
timeout: 60_000,
});
// Keep the paired fixture's spine identical; production conversion remains unchanged.
// kepubify copies instead of converting when its output does not end in .epub.
await rename(`${nativePath}.epub`, nativePath);
await rm(epubPath);
epubPath = nativePath;
}
await triggerAndWaitForLibraryScan(ctx, library.libraryId); await triggerAndWaitForLibraryScan(ctx, library.libraryId);
epub = await locateBookByAbsolutePath(ctx, epubPath); epub = await locateBookByAbsolutePath(ctx, epubPath);
const [scanned] = await ctx.db.select({ format: schema.bookFiles.format }).from(schema.bookFiles).where(eq(schema.bookFiles.id, epub.bookFileId));
expect(scanned!.format).toBe(format);
const [admin] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'reader-state-e2e-admin')); const [admin] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'reader-state-e2e-admin'));
adminUserId = admin!.id; adminUserId = admin!.id;
@@ -131,6 +152,16 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
}); });
expect(settings.statusCode).toBe(200); expect(settings.statusCode).toBe(200);
const context = await ctx.app.get(KoboKepubContextService).resolveForBook(adminUserId, epub.bookId);
expect(context.ok).toBe(true);
if (context.ok) {
const point = await ctx.app
.get(KoboSpanConverterService)
.koboBookmarkToPositions({ bookFileId: context.file.id, ctx: context.ctx, chapterFilename: 'OPS/chapter.xhtml', spanId: 'kobo.1.1' });
expect(point.reason).toBeUndefined();
expect(point).toMatchObject({ status: 'exact', cfi: 'epubcfi(/6/2!/4/2/1:0)' });
}
// Kobo library sync only covers books in collections flagged syncToKobo. // Kobo library sync only covers books in collections flagged syncToKobo.
const collection = await ctx.app.inject({ const collection = await ctx.app.inject({
method: 'POST', method: 'POST',
@@ -161,7 +192,10 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
}, 180_000); }, 180_000);
afterAll(async () => { afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx); if (ctx) {
if (library) await ctx.db.delete(schema.libraries).where(eq(schema.libraries.id, library.libraryId));
await closeReaderStateIsolationE2EContext(ctx);
}
}); });
it('ingests a device highlight into the hub with exact cfi and xpointer positions', async () => { it('ingests a device highlight into the hub with exact cfi and xpointer positions', async () => {
@@ -371,7 +405,7 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
expect(progress.koboLocationValue).toBe('kobo.1.1'); expect(progress.koboLocationValue).toBe('kobo.1.1');
}); });
it('serves a recomputed KoboSpan Location after the web reader moves the position', async () => { it('serves a recomputed KoboSpan Location after an API reader moves the position', async () => {
const save = await ctx.app.inject({ const save = await ctx.app.inject({
method: 'POST', method: 'POST',
url: `/api/v1/books/files/${epub.bookFileId}/progress`, url: `/api/v1/books/files/${epub.bookFileId}/progress`,