fix(kobo): open a newly downloaded book at its saved position

A book's first announcement went to the Kobo before it was downloaded,
when no Kobo location could be computed, so it carried a bare percentage
and the book opened at its start. Compute the location from the copy
being offered for bookmarks sent to the device; positions it reports
still wait for the transfer.
This commit is contained in:
neon
2026-10-17 01:20:03 +00:00
parent ff8759b9a0
commit 5397825003
4 changed files with 53 additions and 6 deletions
@@ -86,4 +86,36 @@ describe('KoboKepubContextService', () => {
ctx: { kepubPath: '/cache/copy.kepub.epub', fileHash: 'sha' },
});
});
it('resolves a first delivery the device has not downloaded yet only for a bookmark sent to it', async () => {
const { file, conversion, binary } = makeContext('epub');
const offered = {
id: 'first',
state: 'offered',
previousId: null,
sourceFileId: 12,
options: { hyphenate: false, converterVersion: 'v1' },
metadata: { format: 'kepub' },
};
const deliveries = {
current: vi.fn().mockResolvedValue(offered),
resolve: vi.fn().mockResolvedValue({ path: '/cache/first.kepub.epub', sha256: 'sha', release: vi.fn() }),
findSourceFile: vi.fn(),
};
const service = new KoboKepubContextService(
{ findPrimaryReaderFilesByBookIds: vi.fn().mockResolvedValue([file]) } as never,
{ getSettings: vi.fn().mockResolvedValue({ convertToKepub: true }) } as never,
conversion as never,
binary as never,
deliveries as never,
);
await expect(service.resolveForBook(7, 3, 11)).resolves.toMatchObject({ ok: false, reason: 'unknown_delivery' });
await expect(service.resolveForBook(7, 3, 11, { outgoing: true })).resolves.toMatchObject({
ok: true,
ctx: { kepubPath: '/cache/first.kepub.epub' },
});
deliveries.current.mockResolvedValue({ ...offered, previousId: 'earlier', options: { ...offered.options, replacement: true } });
await expect(service.resolveForBook(7, 3, 11, { outgoing: true })).resolves.toMatchObject({ ok: false, reason: 'unknown_delivery' });
});
});
@@ -1,6 +1,6 @@
import { Injectable, Optional } from '@nestjs/common';
import { KoboDeliveryService, isSettledDelivery } from './kobo-delivery.service';
import { KoboDeliveryService, isSettledDelivery, isUnconfirmedReplacement } from './kobo-delivery.service';
import { DeliveryArtifactService } from '../../book/delivery-artifact.service';
import { KepubContext } from '../../position-converter/kobo-span-converter.service';
import { BookReadService } from '../../book/book-read.service';
@@ -72,20 +72,25 @@ export class KoboKepubContextService {
* `followDeliverySource` resolves a device whose copy was built from a file that is no longer the
* book's primary against that file, for callers that keep the device's progress on it. Without it such
* a copy is unavailable, since its positions do not belong to the primary file.
*
* `outgoing` also resolves a book's first delivery before the device has downloaded it, for a bookmark
* sent to the device: it downloads exactly those bytes, so a location in them is where the book opens.
* Positions coming from the device are not trusted until the transfer is observed.
*/
async resolveForBook(
userId: number,
bookId: number,
deviceId?: number,
options: { followDeliverySource?: boolean } = {},
options: { followDeliverySource?: boolean; outgoing?: boolean } = {},
): Promise<KepubContextResult> {
if (deviceId && this.deliveries) {
const settings = await this.koboSettingsService.getSettings(userId);
const record = await this.deliveries.current(userId, deviceId, bookId);
if (record && record.state === 'prepared' && record.previousId) return this.resolveForDelivery(userId, bookId, deviceId, record.previousId);
if (!record || record.state === 'offered' || record.state === 'prepared' || record.state === 'cancelled')
const firstOffer = Boolean(options.outgoing && record?.state === 'offered' && !isUnconfirmedReplacement(record));
if (!record || (!firstOffer && ['offered', 'prepared', 'cancelled'].includes(record.state)))
return { ok: false, reason: 'unknown_delivery', settings };
if (!isSettledDelivery(record)) return { ok: false, reason: 'replacement_events_ambiguous', settings };
if (!firstOffer && !isSettledDelivery(record)) return { ok: false, reason: 'replacement_events_ambiguous', settings };
if (record.metadata.format !== 'kepub') return { ok: false, reason: 'kepub_required', settings };
try {
const artifact = await this.deliveries.resolve(userId, deviceId, record);
@@ -23,6 +23,16 @@ describe('KoboProgressBridgeService', () => {
warnSpy.mockRestore();
});
it('lets only a bookmark sent to the device use a copy it has not downloaded yet', async () => {
kepubContextService.resolveForBook.mockResolvedValue({ ok: false, reason: 'unknown_delivery', settings: {} });
await makeService().cfiToKoboBookmark(1, 44, 'epubcfi(/6/8!/4/2/2/1:0)', 7);
await makeService().koboBookmarkToCanonical(1, 44, 'OEBPS/ch7.xhtml', 'kobo.31.1', 7);
expect(kepubContextService.resolveForBook).toHaveBeenNthCalledWith(1, 1, 44, 7, { followDeliverySource: true, outgoing: true });
expect(kepubContextService.resolveForBook).toHaveBeenNthCalledWith(2, 1, 44, 7, { followDeliverySource: true });
});
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)' });
@@ -38,7 +48,7 @@ describe('KoboProgressBridgeService', () => {
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, { followDeliverySource: true });
expect(kepubContextService.resolveForBook).toHaveBeenCalledWith(1, 44, 7, { followDeliverySource: true, outgoing: true });
});
it('degrades to a percent and says why when the narrated sentence cannot be found', async () => {
@@ -95,7 +95,7 @@ export class KoboProgressBridgeService {
async cfiToKoboBookmark(userId: number, bookId: number, cfi: string, deviceId?: number): Promise<KoboBookmarkPoint | null> {
try {
const kepub = await this.kepubContextService.resolveForBook(userId, bookId, deviceId, { followDeliverySource: true });
const kepub = await this.kepubContextService.resolveForBook(userId, bookId, deviceId, { followDeliverySource: true, outgoing: true });
if (!kepub.ok) return this.logDegraded('cfi_to_bookmark', userId, bookId, kepub.reason);
try {