mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format, markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(
|
||||
makeSelectChain('limit', [
|
||||
{
|
||||
|
||||
@@ -2512,7 +2512,7 @@ export class BookRepository {
|
||||
.where(eq(bookFiles.id, fileId))
|
||||
.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 normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
|
||||
|
||||
@@ -3070,11 +3070,11 @@ describe('BookService', () => {
|
||||
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 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.isKoboTwoWayProgressSyncEnabled.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 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.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false);
|
||||
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 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);
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
|
||||
|
||||
@@ -2276,7 +2276,11 @@ export class BookService {
|
||||
// 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
|
||||
// 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(
|
||||
userId,
|
||||
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 };
|
||||
|
||||
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);
|
||||
if (readiness) return { ok: false, reason: readiness, settings };
|
||||
|
||||
|
||||
@@ -557,6 +557,15 @@ describe('KoboReadingStateService', () => {
|
||||
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 () => {
|
||||
const { db, updateChain } = makeRefreshableDb();
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ export class KoboReadingStateService {
|
||||
.select({ fileId: schema.bookFiles.id })
|
||||
.from(schema.books)
|
||||
.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);
|
||||
if (!primaryFile) return null;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as unzipper from 'unzipper';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collapsedPointToCfi } from './position-converter.core';
|
||||
import { EpubDomService, loadChapterFromZip, readEpubSpine } from './epub-dom.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>
|
||||
<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 chunks: Buffer[] = [];
|
||||
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(containerXml, { name: 'META-INF/container.xml' });
|
||||
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' });
|
||||
await archive.finalize();
|
||||
await done;
|
||||
@@ -88,7 +92,7 @@ describe('EpubDomService', () => {
|
||||
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);
|
||||
await buildEpub(prefixedPath, { containerXml: container, contentOpf: opf });
|
||||
const service = new EpubDomService(makeDb(prefixedPath) as never);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const db = makeDb(epubPath, 'pdf');
|
||||
const service = new EpubDomService(db as never);
|
||||
|
||||
@@ -100,15 +100,16 @@ export async function readEpubSpine(zip: unzipper.CentralDirectory): Promise<Epu
|
||||
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);
|
||||
if (!entry) return null;
|
||||
const xhtml = (await entry.buffer()).toString('utf-8');
|
||||
return parseChapterDocument(xhtml);
|
||||
return parseChapterDocument(xhtml, canonicalKepub);
|
||||
}
|
||||
|
||||
interface SpineCacheEntry {
|
||||
absolutePath: string;
|
||||
format: string;
|
||||
mtimeMs: number;
|
||||
spine: EpubSpine;
|
||||
}
|
||||
@@ -137,7 +138,7 @@ export class EpubDomService {
|
||||
const href = entry.spine.hrefs[chapterIndex];
|
||||
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);
|
||||
if (cached) {
|
||||
this.chapterCache.delete(cacheKey);
|
||||
@@ -147,7 +148,7 @@ export class EpubDomService {
|
||||
|
||||
try {
|
||||
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;
|
||||
this.chapterCache.set(cacheKey, doc);
|
||||
while (this.chapterCache.size > CHAPTER_CACHE_MAX) {
|
||||
@@ -167,7 +168,7 @@ export class EpubDomService {
|
||||
.from(bookFiles)
|
||||
.where(eq(bookFiles.id, bookFileId))
|
||||
.limit(1);
|
||||
if (!file || file.format !== 'epub') return null;
|
||||
if (!file || (file.format !== 'epub' && file.format !== 'kepub')) return null;
|
||||
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
@@ -178,12 +179,12 @@ export class EpubDomService {
|
||||
}
|
||||
|
||||
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 {
|
||||
const zip = await unzipper.Open.file(file.absolutePath);
|
||||
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);
|
||||
while (this.spineCache.size > SPINE_CACHE_MAX) {
|
||||
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));
|
||||
}
|
||||
|
||||
function makeDb(absolutePath: string) {
|
||||
const limit = vi.fn().mockResolvedValue([{ absolutePath, format: 'epub' }]);
|
||||
function makeDb(absolutePath: string, format = 'epub') {
|
||||
const limit = vi.fn().mockResolvedValue([{ absolutePath, format }]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
@@ -115,6 +115,50 @@ describe('KoboSpanConverterService (real kepubify output)', () => {
|
||||
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 () => {
|
||||
for (let chapterIndex = 0; chapterIndex < 2; chapterIndex += 1) {
|
||||
const epubDoc = await epubDom.getChapter(1, chapterIndex);
|
||||
|
||||
@@ -48,8 +48,14 @@ export interface 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 });
|
||||
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;
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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 * 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 {
|
||||
authHeader,
|
||||
@@ -39,7 +46,7 @@ function flushTick(): Promise<void> {
|
||||
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 library!: CreatedLibrary;
|
||||
let epub!: LocatedBookFile;
|
||||
@@ -95,12 +102,26 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
|
||||
beforeAll(async () => {
|
||||
ctx = await createReaderStateIsolationE2EContext();
|
||||
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()}`,
|
||||
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);
|
||||
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'));
|
||||
adminUserId = admin!.id;
|
||||
@@ -131,6 +152,16 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
|
||||
});
|
||||
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.
|
||||
const collection = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
@@ -161,7 +192,10 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
|
||||
}, 180_000);
|
||||
|
||||
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 () => {
|
||||
@@ -371,7 +405,7 @@ describe('Kobo annotation sync (e2e)', { timeout: 180_000 }, () => {
|
||||
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({
|
||||
method: 'POST',
|
||||
url: `/api/v1/books/files/${epub.bookFileId}/progress`,
|
||||
|
||||
Reference in New Issue
Block a user