From 539782500334d0957d7993ce07b6e5759883a5fb Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:20:03 +0000 Subject: [PATCH] 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. --- .../kobo-kepub-context.service.test.ts | 32 +++++++++++++++++++ .../services/kobo-kepub-context.service.ts | 13 +++++--- .../kobo-progress-bridge.service.test.ts | 12 ++++++- .../services/kobo-progress-bridge.service.ts | 2 +- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/server/src/modules/kobo/services/kobo-kepub-context.service.test.ts b/server/src/modules/kobo/services/kobo-kepub-context.service.test.ts index 7c81432ea..b30657ad3 100644 --- a/server/src/modules/kobo/services/kobo-kepub-context.service.test.ts +++ b/server/src/modules/kobo/services/kobo-kepub-context.service.test.ts @@ -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' }); + }); }); diff --git a/server/src/modules/kobo/services/kobo-kepub-context.service.ts b/server/src/modules/kobo/services/kobo-kepub-context.service.ts index 6659dd01e..4fba7ea74 100644 --- a/server/src/modules/kobo/services/kobo-kepub-context.service.ts +++ b/server/src/modules/kobo/services/kobo-kepub-context.service.ts @@ -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 { 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); diff --git a/server/src/modules/kobo/services/kobo-progress-bridge.service.test.ts b/server/src/modules/kobo/services/kobo-progress-bridge.service.test.ts index 36131879f..7432dea13 100644 --- a/server/src/modules/kobo/services/kobo-progress-bridge.service.test.ts +++ b/server/src/modules/kobo/services/kobo-progress-bridge.service.test.ts @@ -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 () => { diff --git a/server/src/modules/kobo/services/kobo-progress-bridge.service.ts b/server/src/modules/kobo/services/kobo-progress-bridge.service.ts index 0a4043911..5115f4726 100644 --- a/server/src/modules/kobo/services/kobo-progress-bridge.service.ts +++ b/server/src/modules/kobo/services/kobo-progress-bridge.service.ts @@ -95,7 +95,7 @@ export class KoboProgressBridgeService { async cfiToKoboBookmark(userId: number, bookId: number, cfi: string, deviceId?: number): Promise { 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 {