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