mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user