fix(kobo): open a read-along book at its narrated sentence on the Kobo

A read-along save can hold the sentence being narrated without a text
position, and the Kobo was then sent a bare percentage, so it opened the
book at its start. Turn the narration marker into a text position and a
Kobo location when the saved progress has no cfi.

Ref #1786
This commit is contained in:
neon
2026-10-16 23:59:51 +00:00
parent dd004a3fa7
commit 5051ec0913
5 changed files with 107 additions and 4 deletions
@@ -28,6 +28,8 @@ export class KoboReadingProgressRepository {
koboLocationType: schema.readingProgress.koboLocationType,
koboLocationValue: schema.readingProgress.koboLocationValue,
koboContentSourceProgressPercent: schema.readingProgress.koboContentSourceProgressPercent,
mediaOverlayFragment: schema.readingProgress.mediaOverlayFragment,
mediaOverlaySectionIndex: schema.readingProgress.mediaOverlaySectionIndex,
updatedAt: schema.readingProgress.updatedAt,
})
.from(schema.readingProgress)
@@ -5,12 +5,13 @@ import { KoboProgressBridgeService } from './kobo-progress-bridge.service';
describe('KoboProgressBridgeService', () => {
const kepubContextService = { resolveForBook: vi.fn() };
const koboSpanConverter = { koboBookmarkToPositions: vi.fn(), cfiPointToKoboBookmark: vi.fn() };
const positionConverter = { fragmentToPositions: vi.fn() };
let warnSpy: ReturnType<typeof vi.spyOn>;
const readyContext = { ok: true, file: { id: 55 }, settings: {}, ctx: { kepubPath: '/cache/book.kepub.epub' } };
function makeService() {
return new KoboProgressBridgeService(kepubContextService as never, koboSpanConverter as never);
return new KoboProgressBridgeService(kepubContextService as never, koboSpanConverter as never, positionConverter as never);
}
beforeEach(() => {
@@ -22,6 +23,39 @@ describe('KoboProgressBridgeService', () => {
warnSpy.mockRestore();
});
describe('narrationToKoboBookmark', () => {
it('turns the narrated sentence into a text position, then into a Kobo span', async () => {
positionConverter.fragmentToPositions.mockResolvedValue({ status: 'exact', cfi: 'epubcfi(/6/20!/4/2/82/8,/1:0,/1:0)' });
kepubContextService.resolveForBook.mockResolvedValue(readyContext);
koboSpanConverter.cfiPointToKoboBookmark.mockResolvedValue({
status: 'exact',
spanId: 'kobo.43.3',
chapterFilename: 'OEBPS/xhtml/chapter6.xhtml',
contentSourceProgressPercent: 61.9,
});
await expect(
makeService().narrationToKoboBookmark(1, 44, 55, { fragment: 'OEBPS/xhtml/chapter6.xhtml#chapter6-s161', sectionIndex: 9 }, 7),
).resolves.toEqual({ source: 'OEBPS/xhtml/chapter6.xhtml', value: 'kobo.43.3', contentSourceProgressPercent: 61.9 });
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith({ bookFileId: 55, chapterIndex: 9, fragment: 'chapter6-s161' });
expect(kepubContextService.resolveForBook).toHaveBeenCalledWith(1, 44, 7);
});
it('degrades to a percent and says why when the narrated sentence cannot be found', async () => {
positionConverter.fragmentToPositions.mockResolvedValue({ status: 'failed', reason: 'fragment_not_found' });
await expect(makeService().narrationToKoboBookmark(1, 44, 55, { fragment: 'chapter6.xhtml#gone', sectionIndex: 9 })).resolves.toBeNull();
expect(koboSpanConverter.cfiPointToKoboBookmark).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('op=narration_to_bookmark userId=1 bookId=44 reason=fragment_not_found'));
});
it('returns null rather than failing the sync when the conversion throws', async () => {
positionConverter.fragmentToPositions.mockRejectedValue(new Error('chapter unreadable'));
await expect(makeService().narrationToKoboBookmark(1, 44, 55, { fragment: 'x#y', sectionIndex: 0 })).resolves.toBeNull();
});
});
describe('cfiToKoboBookmark', () => {
it('maps a converted point onto the Kobo bookmark fields', async () => {
kepubContextService.resolveForBook.mockResolvedValue(readyContext);
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { KoboSpanConverterService } from '../../position-converter/kobo-span-converter.service';
import { PositionConverterService } from '../../position-converter/position-converter.service';
import { KoboKepubContextService } from './kobo-kepub-context.service';
const EVENT = 'kobo.progress_bridge';
@@ -29,8 +30,33 @@ export class KoboProgressBridgeService {
constructor(
private readonly kepubContextService: KoboKepubContextService,
private readonly koboSpanConverter: KoboSpanConverterService,
private readonly positionConverter: PositionConverterService,
) {}
/**
* Places a narration marker on the device. A read-along save can hold the sentence being narrated
* without a text position, and a bookmark sent as a bare percent opens the book at its start.
*/
async narrationToKoboBookmark(
userId: number,
bookId: number,
bookFileId: number,
marker: { fragment: string; sectionIndex: number },
deviceId?: number,
): Promise<KoboBookmarkPoint | null> {
try {
const fragment = marker.fragment.slice(marker.fragment.lastIndexOf('#') + 1);
const outcome = await this.positionConverter.fragmentToPositions({ bookFileId, chapterIndex: marker.sectionIndex, fragment });
if (outcome.status === 'failed' || !outcome.cfi) {
return this.logDegraded('narration_to_bookmark', userId, bookId, outcome.reason ?? 'incomplete_outcome');
}
return await this.cfiToKoboBookmark(userId, bookId, outcome.cfi, deviceId);
} catch (error) {
this.logWarn('narration_to_bookmark', userId, bookId, error);
return null;
}
}
async koboBookmarkToCanonical(
userId: number,
bookId: number,
@@ -64,7 +64,7 @@ describe('KoboReadingStateService', () => {
const bookAccessService = { assertBookAccessible: vi.fn() };
const userBookStatusService = { autoUpdate: vi.fn() };
const bookIdentityService = { ensureForBook: vi.fn() };
const progressBridge = { koboBookmarkToCanonical: vi.fn(), cfiToKoboBookmark: vi.fn() };
const progressBridge = { koboBookmarkToCanonical: vi.fn(), cfiToKoboBookmark: vi.fn(), narrationToKoboBookmark: vi.fn() };
const settingsService = { getSettings: vi.fn() };
const achievementEvents = { emit: vi.fn() };
const analyticsResolver = { resolveBookFileId: vi.fn() };
@@ -585,6 +585,38 @@ describe('KoboReadingStateService', () => {
expect(updateChain.set).toHaveBeenCalledWith(expect.objectContaining({ currentBookmark: state.CurrentBookmark }));
});
it('places a read-along narration marker on the device when the saved progress has no text position', async () => {
const db = makeDb();
db.update = vi.fn(() => makeUpdateChain()) as never;
db.query.books.findFirst.mockResolvedValue({ id: 44 });
db.query.koboReadingStates.findFirst.mockResolvedValue(storedState);
db.select
.mockReturnValueOnce(makeSelectChain([{ fileId: 55 }]))
.mockReturnValueOnce(
makeSelectChain([
{ cfi: null, percentage: 70, koboLocationValue: null, mediaOverlayFragment: 'OEBPS/ch6.xhtml#s161', mediaOverlaySectionIndex: 9 },
]),
);
settingsService.getSettings.mockResolvedValue({ twoWayProgressSync: true });
progressBridge.narrationToKoboBookmark.mockResolvedValue({ source: 'OEBPS/ch6.xhtml', value: 'kobo.43.3', contentSourceProgressPercent: 61.9 });
const state = (await makeService(db).getRawState(1, 44)) as Record<string, Record<string, unknown>>;
expect(progressBridge.narrationToKoboBookmark).toHaveBeenCalledWith(
1,
44,
55,
{ fragment: 'OEBPS/ch6.xhtml#s161', sectionIndex: 9 },
undefined,
);
expect(progressBridge.cfiToKoboBookmark).not.toHaveBeenCalled();
expect(state.CurrentBookmark).toMatchObject({
ProgressPercent: 70,
ContentSourceProgressPercent: 61.9,
Location: { Source: 'OEBPS/ch6.xhtml', Type: 'KoboSpan', Value: 'kobo.43.3' },
});
});
// A Kobo clock running ahead is stored verbatim on the device push. Stamping the refresh at
// wall-clock time would land it behind that, and the device keeps its own bookmark on open.
it('stamps the refreshed bookmark past a device timestamp in the future', async () => {
@@ -549,8 +549,17 @@ export class KoboReadingStateService {
const devicePercentage = this.extractPercent(bookmark);
if (!settings.twoWayProgressSync && devicePercentage !== null && progress.percentage <= devicePercentage) return null;
const point =
settings.twoWayProgressSync && progress.cfi ? await this.progressBridge.cfiToKoboBookmark(userId, bookId, progress.cfi, deviceId) : null;
const narration =
progress.mediaOverlayFragment && progress.mediaOverlaySectionIndex !== null
? { fragment: progress.mediaOverlayFragment, sectionIndex: progress.mediaOverlaySectionIndex }
: null;
const point = !settings.twoWayProgressSync
? null
: progress.cfi
? await this.progressBridge.cfiToKoboBookmark(userId, bookId, progress.cfi, deviceId)
: narration
? await this.progressBridge.narrationToKoboBookmark(userId, bookId, fileId, narration, deviceId)
: null;
const existingLocation = this.asJsonObj(bookmark?.Location ?? null);
const sameLocation = point && existingLocation?.Value === point.value && existingLocation?.Source === point.source;