feat(kobo/koreader): two way page level sync between kobo/koreader and bookorbit (#250)

* feat(kobo): support two-way progress sync

Implement two-way page-level reading progress synchronization between Kobo devices, KOReader, and BookOrbit. This ensures readers can switch between physical devices and the web app without losing their exact page-level reading position.

Closes #249

* fix(settings): restore thumbnail click preference

Restore the main branch thumbnail-click setting after the Kobo branch accidentally dropped it during the rebase. Keep the Kobo architecture allowlist aligned with the new identity service.

* fix(kobo): harden two-way progress sync fallbacks

Preserve Kobo bookmark metadata and tolerate null auth payloads/settings failures.

Cover review-reported regressions with focused tests.
This commit is contained in:
neon
2026-06-05 09:30:25 -06:00
committed by GitHub
parent cc7ab54a3b
commit 913dab0ac5
58 changed files with 13467 additions and 350 deletions
+205 -1
View File
@@ -6,6 +6,193 @@ import { makeStreamingLoader } from './streaming-loader.js'
const SEARCH_PREFIX = 'foliate-search:'
const toPercent = (fraction) => (typeof fraction === 'number' && Number.isFinite(fraction) ? Math.max(0, Math.min(100, fraction * 100)) : null)
const getElementForNode = (node) => {
if (!node) return null
if (node.nodeType === 1) return node
return node.parentElement ?? null
}
const getRangePoint = (range) => {
if (!range) return null
if (range.startContainer?.nodeType === 3) return { node: range.startContainer, offset: range.startOffset }
if (range.endContainer?.nodeType === 3) return { node: range.endContainer, offset: range.endOffset }
return { node: range.startContainer, offset: range.startOffset }
}
export const getKoboSpanValue = (range) => {
if (!range) return null
const point = getRangePoint(range)
const direct = getElementForNode(point?.node)?.closest?.('.koboSpan[id^="kobo."]')
if (direct?.id) return direct.id
const doc = range.commonAncestorContainer?.ownerDocument ?? getElementForNode(range.commonAncestorContainer)?.ownerDocument
const spans = doc?.querySelectorAll?.('.koboSpan[id^="kobo."]') ?? []
for (const span of spans) {
try {
if (range.intersectsNode(span)) return span.id
} catch {
// Ignore nodes outside this range's document.
}
}
return null
}
const getXPathStep = (node) => {
if (!node) return null
if (node.nodeType === 3) return 'text()'
if (node.nodeType === 1 && node.localName) return node.localName
return null
}
const getXPathIndex = (node) => {
const step = getXPathStep(node)
if (!step) return 1
const siblings = getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step)
const index = siblings.findIndex((item) => item.node === node)
return index >= 0 ? index + 1 : 1
}
const getXPathStepCount = (node) => {
const step = getXPathStep(node)
if (!step) return 1
return getEffectiveChildren(getEffectiveParent(node)).filter((item) => item.type === 'element' && getXPathStep(item.node) === step).length || 1
}
const getXPathSegment = (node) => {
const step = getXPathStep(node)
if (!step) return null
return getXPathStepCount(node) > 1 ? `${step}[${getXPathIndex(node)}]` : step
}
const isFoliateLayoutWrapper = (node) => node?.nodeType === 1 && (node.id === 'book-columns' || node.id === 'book-inner')
const isKoboSpanWrapper = (node) => node?.nodeType === 1 && node.classList?.contains('koboSpan') && /^kobo\./.test(node.id ?? '')
const isTransparentXPathWrapper = (node) => isFoliateLayoutWrapper(node) || isKoboSpanWrapper(node)
const getEffectiveParent = (node) => {
let parent = node?.parentNode ?? null
while (isTransparentXPathWrapper(parent)) parent = parent.parentNode
return parent
}
const appendEffectiveChild = (items, node) => {
if (!node) return
if (isTransparentXPathWrapper(node)) {
for (const child of node.childNodes) appendEffectiveChild(items, child)
return
}
if (node.nodeType === 3) {
const last = items.at(-1)
if (last?.type === 'text') {
last.nodes.push(node)
return
}
items.push({ type: 'text', nodes: [node] })
return
}
if (node.nodeType === 1) items.push({ type: 'element', node })
}
const getEffectiveChildren = (parent) => {
const items = []
for (const child of parent?.childNodes ?? []) appendEffectiveChild(items, child)
return items
}
const getBodyXPath = (node) => {
if (!node) return null
const doc = node.ownerDocument ?? (node.nodeType === 9 ? node : null)
const body = doc?.body
if (!body) return null
const parts = []
let current = node
while (current) {
if (current === body) {
parts.unshift('body')
return `/${parts.join('/')}`
}
if (!isTransparentXPathWrapper(current)) {
const segment = getXPathSegment(current)
if (!segment) return null
parts.unshift(segment)
}
current = getEffectiveParent(current)
}
return null
}
const getKoreaderTextTarget = (point) => {
if (point?.node?.nodeType !== 3) return null
const parent = getEffectiveParent(point.node)
if (!parent) return null
const textItems = getEffectiveChildren(parent).filter((item) => item.type === 'text')
for (const [itemIndex, item] of textItems.entries()) {
let offset = 0
for (const node of item.nodes) {
if (node === point.node) {
return {
parent,
index: itemIndex + 1,
count: textItems.length,
offset: offset + (Number.isFinite(point.offset) ? point.offset : 0),
}
}
offset += node.nodeValue?.length ?? 0
}
}
return null
}
const getSectionPathBasename = (section) => {
if (typeof section?.id !== 'string') return null
const path = section.id.split(/[?#]/)[0]
return path.split('/').pop() ?? null
}
const isKepubifySyntheticSection = (section) => getSectionPathBasename(section) === 'kepubify-titlepage-dummy.xhtml'
export const getKoreaderDocFragmentIndex = (sections, index) => {
if (!Array.isArray(sections) || typeof index !== 'number') return null
const section = sections[index]
if (!section || isKepubifySyntheticSection(section)) return null
let docFragmentIndex = 0
for (let i = 0; i <= index && i < sections.length; i += 1) {
if (!isKepubifySyntheticSection(sections[i])) docFragmentIndex += 1
}
return docFragmentIndex || null
}
export const getKoreaderProgress = (docFragmentIndex, range) => {
if (!range || typeof docFragmentIndex !== 'number') return null
const point = getRangePoint(range)
if (!point) return null
const target = getKoreaderTextTarget(point)
if (!target) return null
const xpath = getBodyXPath(target.parent)
if (!xpath) return null
const textStep = target.count > 1 ? `text()[${target.index}]` : 'text()'
return `/body/DocFragment[${docFragmentIndex}]${xpath}/${textStep}.${target.offset}`
}
const isZip = async (file) => {
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
@@ -397,7 +584,24 @@ export class View extends HTMLElement {
const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range)
const cfi = this.getCFI(index, range)
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
const source = this.book.sections[index]?.id ?? null
const contentSourceProgressPercent = toPercent(fraction)
const koboLocationValue = getKoboSpanValue(range)
const koboLocationType = koboLocationValue ? 'KoboSpan' : null
const koreaderDocFragmentIndex = getKoreaderDocFragmentIndex(this.book.sections, index)
const koreaderProgress = getKoreaderProgress(koreaderDocFragmentIndex, range)
this.lastLocation = {
...progress,
tocItem,
pageItem,
cfi,
range,
source,
koboLocationType,
koboLocationValue,
contentSourceProgressPercent,
koreaderProgress,
}
if (reason === 'snap' || reason === 'page' || reason === 'scroll') this.history.replaceState(cfi)
this.#emit('relocate', this.lastLocation)
}
@@ -658,7 +658,7 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
if (canViewKoreader.value && koreaderBookProgress.value != null && koreaderBookProgress.value.canonicalPercentage > 0) {
const koreaderColor = '#b3b910'
rows.push({
label: 'KO',
label: 'KO-R',
percentage: koreaderBookProgress.value.canonicalPercentage,
color: koreaderColor,
badgeStyle: { color: koreaderColor, borderColor: `${koreaderColor}66`, backgroundColor: `${koreaderColor}1a` },
@@ -676,9 +676,9 @@ const koboAnomaly = computed(() => {
if (!canViewKobo.value) return null
const snap = koboState.value?.snapshot
if (!snap) return null
if (snap.pendingDelete) return 'Pending delete from device'
if (snap.removedByDevice) return 'Removed by device'
if (snap.synced === false) return 'Not synced'
if (snap.pendingDelete) return { label: 'Pending delete from device', tooltip: 'Kobo will remove it on next sync.' }
if (snap.removedByDevice) return { label: 'Removed by device', tooltip: 'Kobo reported this book removed.' }
if (snap.synced === false) return { label: 'Not synced', tooltip: 'Queued for next Kobo sync.' }
return null
})
@@ -1309,10 +1309,15 @@ watch(
</div>
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
</div>
<div v-if="koboAnomaly" class="mt-2 flex items-center gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly }}</p>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
</div>
</TooltipTrigger>
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1686,10 +1691,15 @@ watch(
</div>
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
</div>
<div v-if="koboAnomaly" class="flex items-center gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly }}</p>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="flex items-center gap-1.5 cursor-help" tabindex="0">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
</div>
</TooltipTrigger>
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
</Tooltip>
</div>
<!-- Synopsis -->
@@ -8,6 +8,7 @@ const settings = ref<KoboSyncSettings>({
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
})
let fetchPromise: Promise<void> | null = null
@@ -36,6 +36,9 @@ describe('Foliate navigation', () => {
}
goTo: (target: string) => Promise<{ index: number } | undefined>
}
let getKoreaderProgress: (index: number, range: Range | null) => string | null
let getKoreaderDocFragmentIndex: (sections: { id: string }[], index: number) => number | null
let getKoboSpanValue: (range: Range | null) => string | null
let Paginator: new () => {
sections: { load: () => Promise<string | null> }[]
goTo: (target: { index: number }) => Promise<void>
@@ -46,7 +49,12 @@ describe('Foliate navigation', () => {
const viewModulePath = '../../../../../public/assets/foliate/view.js'
const paginatorModulePath = '../../../../../public/assets/foliate/paginator.js'
;({ View } = (await import(viewModulePath)) as { View: typeof View })
;({ View, getKoreaderProgress, getKoreaderDocFragmentIndex, getKoboSpanValue } = (await import(viewModulePath)) as {
View: typeof View
getKoreaderProgress: typeof getKoreaderProgress
getKoreaderDocFragmentIndex: typeof getKoreaderDocFragmentIndex
getKoboSpanValue: typeof getKoboSpanValue
})
;({ Paginator } = (await import(paginatorModulePath)) as { Paginator: typeof Paginator })
})
@@ -99,4 +107,110 @@ describe('Foliate navigation', () => {
await expect(paginator.goTo({ index: 0 })).rejects.toThrow('Failed to load section 0')
})
it('preserves real inline elements for KOReader XPointer progress', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<p>First paragraph</p><p><span>Middle text</span> tail</p>'
const text = doc.querySelector('p:nth-of-type(2) span')?.firstChild
if (!text) throw new Error('Expected text node')
const range = doc.createRange()
range.setStart(text, 2)
range.setEnd(text, 8)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p[2]/span/text().2')
})
it('uses the text endpoint and strips Foliate layout wrappers when the range starts on a container', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<div id="book-columns"><div id="book-inner"><div id="filepos48449"></div><p><span id="kobo.10.6">Middle text</span></p></div></div>'
const start = doc.querySelector('#filepos48449')
const text = doc.querySelector('#kobo\\.10\\.6')?.firstChild
if (!start || !text) throw new Error('Expected test nodes')
const range = doc.createRange()
range.setStart(start, 0)
range.setEnd(text, 6)
expect(getKoreaderProgress(7, range)).toBe('/body/DocFragment[7]/body/p/span/text().6')
})
it('merges adjacent KoboSpan text wrappers for KOReader text node offsets', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<p><span class="koboSpan" id="kobo.5.1">“</span><span class="italic"><span class="koboSpan" id="kobo.5.2">Fascinating</span></span><span class="koboSpan" id="kobo.5.3">!” </span><span class="koboSpan" id="kobo.5.4">he would say as Harry talked him through using a telephone. </span><span class="koboSpan" id="kobo.5.5">“</span><span class="italic"><span class="koboSpan" id="kobo.5.6">Ingenious,</span></span><span class="koboSpan" id="kobo.5.7"> really, how many ways Muggles have found of getting along without magic.”</span></p>'
const text = doc.querySelector('#kobo\\.5\\.4')?.firstChild
if (!text) throw new Error('Expected text node')
const range = doc.createRange()
range.setStart(text, 33)
range.setEnd(text, 33)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p/text()[2].36')
})
it('uses the KoboSpan-local offset inside the original EPUB text node', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<p><span class="italic"><span class="koboSpan" id="kobo.13.1">Gadding with Ghouls</span></span><span class="koboSpan" id="kobo.13.2"> by Gilderoy Lockhart</span></p>'
const text = doc.querySelector('#kobo\\.13\\.2')?.firstChild
if (!text) throw new Error('Expected text node')
const range = doc.createRange()
range.setStart(text, 21)
range.setEnd(text, 21)
expect(getKoreaderProgress(8, range)).toBe('/body/DocFragment[8]/body/p/text().21')
})
it('maps KEPUB sections back to KOReader DocFragment indexes', () => {
const sections = [
{ id: 'kepubify-titlepage-dummy.xhtml' },
{ id: 'text/part0000_split_000.html' },
{ id: 'text/part0000_split_000.html' },
{ id: 'text/part0000_split_001.html' },
{ id: 'text/part0001.html' },
{ id: 'text/part0002.html' },
{ id: 'text/part0003.html' },
{ id: 'text/part0004.html' },
{ id: 'text/part0005.html' },
{ id: 'text/part0006.html' },
{ id: 'text/part0007.html' },
{ id: 'text/part0008.html' },
{ id: 'text/part0009.html' },
{ id: 'text/part0010.html' },
{ id: 'text/part0011.html' },
{ id: 'text/part0012.html' },
{ id: 'text/part0013.html' },
{ id: 'text/part0014.html' },
{ id: 'text/part0015.html' },
{ id: 'text/part0016.html' },
]
expect(getKoreaderDocFragmentIndex(sections, 0)).toBeNull()
expect(getKoreaderDocFragmentIndex(sections, 1)).toBe(1)
expect(getKoreaderDocFragmentIndex(sections, 2)).toBe(2)
expect(getKoreaderDocFragmentIndex(sections, 19)).toBe(19)
})
it('uses the selected text point for KoboSpan instead of the first span in a wide range', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<div id="filepos48449"></div><p><span class="koboSpan" id="kobo.1.1">Start</span></p><p><span class="koboSpan" id="kobo.10.6">Middle text</span></p>'
const start = doc.querySelector('#filepos48449')
const text = doc.querySelector('#kobo\\.10\\.6')?.firstChild
if (!start || !text) throw new Error('Expected test nodes')
const range = doc.createRange()
range.setStart(start, 0)
range.setEnd(text, 6)
expect(getKoboSpanValue(range)).toBe('kobo.10.6')
})
})
@@ -38,6 +38,7 @@ describe('useFoliate.open', () => {
let container: HTMLDivElement
let mockGoTo: ReturnType<typeof vi.fn>
let mockGoToFraction: ReturnType<typeof vi.fn>
let includeGoToFraction: boolean
beforeEach(() => {
container = document.createElement('div')
@@ -45,21 +46,23 @@ describe('useFoliate.open', () => {
mockGoTo = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
mockGoToFraction = vi.fn<() => void>()
includeGoToFraction = true
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'foliate-view') {
const el = originalCreateElement('div')
Object.assign(el, {
const view = {
open: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
goTo: mockGoTo,
goToFraction: mockGoToFraction,
renderer: {
setAttribute: vi.fn<(name: string, value: string) => void>(),
removeAttribute: vi.fn<(name: string) => void>(),
},
destroy: vi.fn<() => void>(),
})
} as Record<string, unknown>
if (includeGoToFraction) view.goToFraction = mockGoToFraction
Object.assign(el, view)
return el
}
return originalCreateElement(tag)
@@ -98,6 +101,16 @@ describe('useFoliate.open', () => {
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
})
it('falls back to position 0 when fallback fraction navigation is unavailable', async () => {
includeGoToFraction = false
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, 0.42)
expect(mockGoToFraction).not.toHaveBeenCalled()
expect(mockGoTo).toHaveBeenCalledWith(0)
})
it('navigates to position 0 when cfi is null and fraction is 0', async () => {
const foliate = useFoliate(() => container)
@@ -123,6 +136,16 @@ describe('useFoliate.open', () => {
await expect(foliate.open(1, 1, 'epub', 'epubcfi(/bad)', undefined)).resolves.toBeUndefined()
})
it('falls back to fraction when CFI navigation rejects', async () => {
mockGoTo.mockRejectedValue(new Error('invalid CFI'))
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', 'epubcfi(/bad)', 0.42)
expect(mockGoTo).toHaveBeenCalledWith('epubcfi(/bad)')
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
})
it('does not call goTo or goToFraction when container is null', async () => {
const foliate = useFoliate(() => null)
@@ -9,6 +9,11 @@ export interface RelocateDetail {
cfi?: string | null
fraction?: number
index?: number
source?: string | null
koboLocationType?: string | null
koboLocationValue?: string | null
contentSourceProgressPercent?: number | null
koreaderProgress?: string | null
total?: number
tocItem?: { label?: string; href?: string }
section?: { current: number; total: number }
@@ -118,6 +123,17 @@ export function useFoliate(
view.addEventListener('relocate', (e: Event) => {
const detail = (e as CustomEvent).detail
fraction.value = detail?.fraction ?? 0
if (import.meta.env.DEV) {
console.info('[foliate.relocate]', {
source: detail?.source ?? null,
koboLocationType: detail?.koboLocationType ?? null,
koboLocationValue: detail?.koboLocationValue ?? null,
contentSourceProgressPercent: detail?.contentSourceProgressPercent ?? null,
koreaderProgress: detail?.koreaderProgress ?? null,
fraction: detail?.fraction ?? null,
cfi: detail?.cfi ?? null,
})
}
onRelocate?.(detail)
})
@@ -159,11 +175,26 @@ export function useFoliate(
await view.open(file)
}
if (onApplyStyles) onApplyStyles(view.renderer)
let didNavigate = false
if (cfi) {
await view.goTo(cfi).catch(() => {})
} else if (fallbackFraction !== undefined && fallbackFraction > 0) {
view.goToFraction?.(fallbackFraction)
} else {
await view
.goTo(cfi)
.then(() => {
didNavigate = true
})
.catch(() => {})
}
if (!didNavigate && fallbackFraction !== undefined && fallbackFraction > 0) {
if (typeof view.goToFraction === 'function') {
try {
view.goToFraction(fallbackFraction)
didNavigate = true
} catch {
didNavigate = false
}
}
}
if (!didNavigate) {
await view.goTo(0).catch(() => {})
}
} catch (e) {
@@ -60,6 +60,11 @@ describe('useReaderProgress', () => {
cfi: 'epubcfi(/6/4)',
fraction: 0.35,
index: 2,
source: 'OEBPS/html/ch3.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
contentSourceProgressPercent: 25,
koreaderProgress: '/body/DocFragment[3]/body/p[4]/text()[1].2',
total: 10,
tocItem: { label: 'Chapter 3', href: 'ch3.xhtml' },
section: { current: 2, total: 10 },
@@ -76,6 +81,11 @@ describe('useReaderProgress', () => {
expect(progress.cfi.value).toBe('epubcfi(/6/4)')
expect(progress.fraction.value).toBe(0.35)
expect(progress.percentage.value).toBe(35)
expect(progress.koboLocationSource.value).toBe('OEBPS/html/ch3.xhtml')
expect(progress.koboLocationType.value).toBe('KoboSpan')
expect(progress.koboLocationValue.value).toBe('kobo.25.1')
expect(progress.koboContentSourceProgressPercent.value).toBe(25)
expect(progress.koreaderProgress.value).toBe('/body/DocFragment[3]/body/p[4]/text()[1].2')
expect(progress.chapterTitle.value).toBe('Chapter 3')
expect(progress.sectionIndex.value).toBe(2)
expect(progress.totalSections.value).toBe(10)
@@ -190,6 +200,19 @@ describe('useReaderProgress', () => {
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/progress') && (c[1] as { method?: string })?.method === 'POST',
)
expect(saveCalls.length).toBe(1)
const saveCall = saveCalls[0]
if (!saveCall) throw new Error('Expected one progress save call')
const saveBody = JSON.parse((saveCall[1] as { body: string }).body)
expect(saveBody).toEqual(
expect.objectContaining({
percentage: 30,
koboLocationSource: 'OEBPS/html/ch3.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 25,
koreaderProgress: '/body/DocFragment[3]/body/p[4]/text()[1].2',
}),
)
vi.useRealTimers()
})
@@ -28,6 +28,11 @@ export function useReaderProgress(
const cfi = ref<string | null>(null)
const pageNumber = ref<number | null>(null)
const percentage = ref(0)
const koboLocationSource = ref<string | null>(null)
const koboLocationType = ref<string | null>(null)
const koboLocationValue = ref<string | null>(null)
const koboContentSourceProgressPercent = ref<number | null>(null)
const koreaderProgress = ref<string | null>(null)
const chapterTitle = ref('')
const sectionIndex = ref(0)
const totalSections = ref(0)
@@ -57,12 +62,22 @@ export function useReaderProgress(
cfi.value = data.cfi ?? null
pageNumber.value = data.pageNumber ?? null
percentage.value = data.percentage ?? 0
koboLocationSource.value = data.koboLocationSource ?? null
koboLocationType.value = data.koboLocationType ?? null
koboLocationValue.value = data.koboLocationValue ?? null
koboContentSourceProgressPercent.value = data.koboContentSourceProgressPercent ?? null
koreaderProgress.value = data.koreaderProgress ?? null
}
function onRelocate(detail: RelocateDetail) {
cfi.value = detail?.cfi ?? null
fraction.value = detail?.fraction ?? 0
percentage.value = fraction.value * 100
koboLocationSource.value = detail?.source ?? null
koboLocationValue.value = detail?.koboLocationValue ?? null
koboLocationType.value = koboLocationValue.value ? (detail?.koboLocationType ?? 'KoboSpan') : null
koboContentSourceProgressPercent.value = detail?.contentSourceProgressPercent ?? null
koreaderProgress.value = detail?.koreaderProgress ?? null
chapterTitle.value = detail?.tocItem?.label ?? ''
sectionIndex.value = detail?.section?.current ?? detail?.index ?? 0
totalSections.value = detail?.section?.total ?? detail?.total ?? 0
@@ -88,6 +103,11 @@ export function useReaderProgress(
cfi: cfi.value,
pageNumber: pageNumber.value,
percentage: percentage.value,
koboLocationSource: koboLocationSource.value,
koboLocationType: koboLocationType.value,
koboLocationValue: koboLocationValue.value,
koboContentSourceProgressPercent: koboContentSourceProgressPercent.value,
koreaderProgress: koreaderProgress.value,
}),
})
}
@@ -211,6 +231,11 @@ export function useReaderProgress(
cfi,
pageNumber,
percentage,
koboLocationSource,
koboLocationType,
koboLocationValue,
koboContentSourceProgressPercent,
koreaderProgress,
chapterTitle,
sectionIndex,
totalSections,
+37 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
@@ -37,9 +37,19 @@ const finishedThreshold = ref(99)
const convertToKepub = ref(true)
const forceEnableHyphenation = ref(false)
const kepubConversionLimitMb = ref(100)
const twoWayProgressSync = ref(false)
const savingSettings = ref(false)
const settingsError = ref<string | null>(null)
function applySettingsToLocal() {
readingThreshold.value = settings.value.readingThreshold
finishedThreshold.value = settings.value.finishedThreshold
convertToKepub.value = settings.value.convertToKepub
forceEnableHyphenation.value = settings.value.forceEnableHyphenation
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
twoWayProgressSync.value = settings.value.twoWayProgressSync
}
function formatLastSeen(date: string | null): string {
if (!date) return 'Never'
const d = new Date(date)
@@ -57,11 +67,7 @@ function formatLastSeen(date: string | null): string {
onMounted(async () => {
try {
await Promise.all([fetchDevices(), fetchSettings()])
readingThreshold.value = settings.value.readingThreshold
finishedThreshold.value = settings.value.finishedThreshold
convertToKepub.value = settings.value.convertToKepub
forceEnableHyphenation.value = settings.value.forceEnableHyphenation
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
applySettingsToLocal()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
@@ -69,6 +75,10 @@ onMounted(async () => {
}
})
watch(twoWayProgressSync, (enabled) => {
if (enabled) convertToKepub.value = true
})
async function submitCreate() {
if (!newDeviceName.value.trim()) return
creating.value = true
@@ -154,7 +164,9 @@ async function saveSettings() {
convertToKepub: convertToKepub.value,
forceEnableHyphenation: forceEnableHyphenation.value,
kepubConversionLimitMb: kepubConversionLimitMb.value,
twoWayProgressSync: twoWayProgressSync.value,
})
applySettingsToLocal()
toast.success('Kobo sync settings saved')
} catch (e) {
settingsError.value = e instanceof Error ? e.message : 'Failed to save'
@@ -289,10 +301,23 @@ async function saveSettings() {
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">Convert to KEPUB</p>
<p class="settings-hint">Optimizes ebooks for Kobo devices with better performance and features.</p>
<p class="settings-label">Two-way progress sync</p>
<p class="settings-hint">
Syncs reading position between BookOrbit and Kobo. For reliable page restore on Kobo, books must be sent as KEPUB.
</p>
</div>
<ToggleSwitch v-model="convertToKepub" />
<ToggleSwitch v-model="twoWayProgressSync" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">Convert to KEPUB</p>
<p class="settings-hint">
Required when two-way progress sync is enabled. On the next Kobo sync, affected books are offered again as KEPUB downloads. If Kobo
keeps opening an old EPUB copy, remove that book from Kobo and sync again.
</p>
</div>
<ToggleSwitch v-model="convertToKepub" :disabled="twoWayProgressSync" />
</div>
<div v-if="convertToKepub" class="flex items-center justify-between px-5 py-4 bg-card">
@@ -334,7 +359,9 @@ async function saveSettings() {
<span class="text-xs font-mono text-primary font-bold">{{ kepubConversionLimitMb }} MB</span>
</div>
<input v-model.number="kepubConversionLimitMb" type="range" min="1" max="500" step="5" class="w-full accent-primary cursor-pointer" />
<p class="text-[12px] text-muted-foreground mt-2">Skip conversion for files larger than this to save server resources.</p>
<p class="text-[12px] text-muted-foreground mt-2">
Books above this limit are sent as regular EPUBs, so BookOrbit will not sync their reader position back to Kobo.
</p>
</div>
</div>
@@ -359,8 +359,8 @@ async function handleRefresh() {
<div class="px-4 py-3.5 bg-card md:px-5">
<p class="settings-label">Cross-reader compatibility</p>
<p class="settings-hint">
KOReader and BookOrbit use different internal progress formats, so cross-reader location restore is not fully compatible. KOReader to
BookOrbit is typically more precise, while BookOrbit to KOReader for EPUB usually resumes at chapter level.
BookOrbit saves KOReader-compatible EPUB locations when progress is saved from the web reader. Restore should land close to the same
position in both directions, but exact line placement can still vary between readers and EPUB layouts.
</p>
</div>
</div>
+2
View File
@@ -15,6 +15,7 @@ export interface KoboSyncSettings {
convertToKepub: boolean;
forceEnableHyphenation: boolean;
kepubConversionLimitMb: number;
twoWayProgressSync: boolean;
}
export interface CreateKoboDeviceRequest {
@@ -31,4 +32,5 @@ export interface UpdateKoboSyncSettingsRequest {
convertToKepub?: boolean;
forceEnableHyphenation?: boolean;
kepubConversionLimitMb?: number;
twoWayProgressSync?: boolean;
}
@@ -0,0 +1,27 @@
CREATE TABLE "kobo_book_entitlements" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"book_id" integer NOT NULL,
"entitlement_id" uuid DEFAULT gen_random_uuid() NOT NULL,
"cover_image_id" uuid DEFAULT gen_random_uuid() NOT NULL,
"needs_legacy_numeric_removal" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "kobo_book_entitlements_user_book_unique" UNIQUE("user_id","book_id"),
CONSTRAINT "kobo_book_entitlements_user_entitlement_unique" UNIQUE("user_id","entitlement_id"),
CONSTRAINT "kobo_book_entitlements_user_cover_unique" UNIQUE("user_id","cover_image_id")
);
--> statement-breakpoint
ALTER TABLE "kobo_sync_settings" ALTER COLUMN "convert_to_kepub" SET DEFAULT true;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD COLUMN "kobo_location_source" varchar(4096);--> statement-breakpoint
ALTER TABLE "reading_progress" ADD COLUMN "kobo_location_type" varchar(64);--> statement-breakpoint
ALTER TABLE "reading_progress" ADD COLUMN "kobo_location_value" varchar(255);--> statement-breakpoint
ALTER TABLE "reading_progress" ADD COLUMN "kobo_content_source_progress_percent" real;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD COLUMN "koreader_progress" text;--> statement-breakpoint
ALTER TABLE "kobo_snapshot_books" ADD COLUMN "delivery_hash" varchar(64);--> statement-breakpoint
ALTER TABLE "kobo_sync_settings" ADD COLUMN "two_way_progress_sync" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "kobo_book_entitlements" ADD CONSTRAINT "kobo_book_entitlements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kobo_book_entitlements" ADD CONSTRAINT "kobo_book_entitlements_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "kobo_book_entitlements_user_id_idx" ON "kobo_book_entitlements" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "kobo_book_entitlements_book_id_idx" ON "kobo_book_entitlements" USING btree ("book_id");--> statement-breakpoint
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_kobo_content_source_progress_range_chk" CHECK ("reading_progress"."kobo_content_source_progress_percent" is null or ("reading_progress"."kobo_content_source_progress_percent" >= 0 and "reading_progress"."kobo_content_source_progress_percent" <= 100));
File diff suppressed because it is too large Load Diff
@@ -99,6 +99,13 @@
"when": 1780425838875,
"tag": "0013_add_series_ids",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1780622112610,
"tag": "0014_kobo-two-way-progress-sync",
"breakpoints": true
}
]
}
+33 -2
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm';
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, real, serial, timestamp, unique, uuid, varchar } from 'drizzle-orm/pg-core';
import { books } from './books';
import { users } from './auth';
@@ -29,9 +29,10 @@ export const koboSyncSettings = pgTable(
.unique(),
readingThreshold: real('reading_threshold').notNull().default(1),
finishedThreshold: real('finished_threshold').notNull().default(99),
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
convertToKepub: boolean('convert_to_kepub').notNull().default(true),
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
@@ -72,6 +73,7 @@ export const koboSnapshotBooks = pgTable(
isNew: boolean('is_new').notNull().default(true),
removedByDevice: boolean('removed_by_device').notNull().default(false),
fileHash: varchar('file_hash', { length: 64 }),
deliveryHash: varchar('delivery_hash', { length: 64 }),
metadataHash: varchar('metadata_hash', { length: 64 }),
},
(t) => [
@@ -80,6 +82,34 @@ export const koboSnapshotBooks = pgTable(
],
);
export const koboBookEntitlements = pgTable(
'kobo_book_entitlements',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
entitlementId: uuid('entitlement_id').notNull().defaultRandom(),
coverImageId: uuid('cover_image_id').notNull().defaultRandom(),
needsLegacyNumericRemoval: boolean('needs_legacy_numeric_removal').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
unique('kobo_book_entitlements_user_book_unique').on(t.userId, t.bookId),
unique('kobo_book_entitlements_user_entitlement_unique').on(t.userId, t.entitlementId),
unique('kobo_book_entitlements_user_cover_unique').on(t.userId, t.coverImageId),
index('kobo_book_entitlements_user_id_idx').on(t.userId),
index('kobo_book_entitlements_book_id_idx').on(t.bookId),
],
);
export const koboReadingStates = pgTable(
'kobo_reading_states',
{
@@ -111,4 +141,5 @@ export type NewKoboDevice = typeof koboDevices.$inferInsert;
export type KoboSyncSetting = typeof koboSyncSettings.$inferSelect;
export type KoboLibrarySnapshot = typeof koboLibrarySnapshots.$inferSelect;
export type KoboSnapshotBook = typeof koboSnapshotBooks.$inferSelect;
export type KoboBookEntitlement = typeof koboBookEntitlements.$inferSelect;
export type KoboReadingState = typeof koboReadingStates.$inferSelect;
+13
View File
@@ -86,6 +86,15 @@ export const readingProgress = pgTable(
pageNumber: integer('page_number'),
// Audio: playback position in seconds
positionSeconds: real('position_seconds'),
// Kobo: spine XHTML resource used as Location.Source
koboLocationSource: varchar('kobo_location_source', { length: 4096 }),
// Kobo: exact bookmark location kind/value, usually KoboSpan + kobo.x.y for KEPUB
koboLocationType: varchar('kobo_location_type', { length: 64 }),
koboLocationValue: varchar('kobo_location_value', { length: 255 }),
// Kobo: progress within Location.Source
koboContentSourceProgressPercent: real('kobo_content_source_progress_percent'),
// KOReader: XPointer progress string generated from the EPUB DOM
koreaderProgress: text('koreader_progress'),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
@@ -98,6 +107,10 @@ export const readingProgress = pgTable(
check('reading_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
check('reading_progress_page_number_nonnegative_chk', sql`${t.pageNumber} is null or ${t.pageNumber} >= 0`),
check('reading_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} is null or ${t.positionSeconds} >= 0`),
check(
'reading_progress_kobo_content_source_progress_range_chk',
sql`${t.koboContentSourceProgressPercent} is null or (${t.koboContentSourceProgressPercent} >= 0 and ${t.koboContentSourceProgressPercent} <= 100)`,
),
],
);
@@ -50,6 +50,7 @@ describe('Architecture boundaries', () => {
'src/modules/cover/cover.service.ts',
'src/modules/entity-manager/duplicate-compute.service.ts',
'src/modules/kobo/services/kobo-book-access.service.ts',
'src/modules/kobo/services/kobo-book-identity.service.ts',
'src/modules/kobo/services/kobo-device.service.ts',
'src/modules/kobo/services/kobo-download.service.ts',
'src/modules/kobo/services/kobo-reading-state.service.ts',
@@ -741,7 +741,16 @@ describe('BookController', () => {
const user = makeUser();
bookService.getProgress.mockResolvedValue(null);
await expect(controller.getFileProgress(9, user)).resolves.toEqual({ cfi: null, pageNumber: null, percentage: 0 });
await expect(controller.getFileProgress(9, user)).resolves.toEqual({
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
});
expect(bookService.getProgress).toHaveBeenCalledWith(user.id, 9, user);
});
+12 -1
View File
@@ -476,7 +476,18 @@ export class BookController {
@Get('files/:fileId/progress')
async getFileProgress(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser) {
return (await this.bookService.getProgress(user.id, fileId, user)) ?? { cfi: null, pageNumber: null, percentage: 0 };
return (
(await this.bookService.getProgress(user.id, fileId, user)) ?? {
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
}
);
}
@Post('files/:fileId/progress')
+226 -6
View File
@@ -38,6 +38,12 @@ function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
return chain;
}
function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
return { values, onConflictDoUpdate };
}
describe('BookRepository', () => {
it('runs callbacks inside db transactions', async () => {
const db = {
@@ -194,11 +200,26 @@ describe('BookRepository', () => {
const findCollectionsChain = makeSelectChain('orderBy', [{ id: 1, name: 'Favorites' }]);
const libraryIdChain = makeSelectChain('limit', [{ libraryId: 5 }]);
const missingLibraryChain = makeSelectChain('limit', []);
const fileByIdChain = makeSelectChain('limit', [{ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2 }]);
const fileByIdChain = makeSelectChain('limit', [
{ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2, fileHash: null, sizeBytes: null },
]);
const missingFileChain = makeSelectChain('limit', []);
const progressChain = makeSelectChain('limit', [{ percentage: 12 }]);
const missingProgressChain = makeSelectChain('limit', []);
const progressByBookChain = makeSelectChain('orderBy', [{ fileId: 1, cfi: null, pageNumber: null, percentage: 0, updatedAt: null }]);
const progressByBookChain = makeSelectChain('orderBy', [
{
fileId: 1,
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
]);
const koboReadingChain = makeSelectChain('limit', [{ createdAtKobo: null }]);
const koboSnapshotChain = makeSelectChain('limit', [{ snapshotId: 8 }]);
const koboCollectionsChain = makeSelectChain('where', [{ name: 'Sync Me' }]);
@@ -222,11 +243,32 @@ describe('BookRepository', () => {
await expect(repo.findCollectionsByBookId(10, 1)).resolves.toEqual([{ id: 1, name: 'Favorites' }]);
await expect(repo.findLibraryIdByBookId(10)).resolves.toBe(5);
await expect(repo.findLibraryIdByBookId(11)).resolves.toBeNull();
await expect(repo.findFileById(9)).resolves.toEqual({ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2 });
await expect(repo.findFileById(9)).resolves.toEqual({
id: 9,
absolutePath: '/books/a.epub',
format: 'epub',
bookId: 1,
libraryId: 2,
fileHash: null,
sizeBytes: null,
});
await expect(repo.findFileById(10)).resolves.toBeNull();
await expect(repo.findProgress(1, 9)).resolves.toEqual({ percentage: 12 });
await expect(repo.findProgress(1, 10)).resolves.toBeNull();
await expect(repo.findProgressByBook(1, 10)).resolves.toEqual([{ fileId: 1, cfi: null, pageNumber: null, percentage: 0, updatedAt: null }]);
await expect(repo.findProgressByBook(1, 10)).resolves.toEqual([
{
fileId: 1,
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
]);
await expect(repo.findKoboReadingState(1, 10)).resolves.toEqual({ createdAtKobo: null });
await expect(repo.findKoboSnapshotState(1, 10)).resolves.toEqual({ snapshotId: 8 });
await expect(repo.findKoboSyncCollectionNamesForBook(1, 10)).resolves.toEqual(['Sync Me']);
@@ -532,7 +574,19 @@ describe('BookRepository', () => {
const db = { insert };
const repo = new BookRepository(db as never);
await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', 7, 80);
await repo.upsertProgress(
5,
9,
'epubcfi(/6/2)',
7,
80,
null,
'OEBPS/ch1.xhtml',
'KoboSpan',
'kobo.25.1',
25,
'/body/DocFragment[2]/body/p[1]/text()[1].0',
);
expect(insert).toHaveBeenCalledTimes(1);
expect(values).toHaveBeenCalledWith(
@@ -542,16 +596,182 @@ describe('BookRepository', () => {
cfi: 'epubcfi(/6/2)',
pageNumber: 7,
percentage: 80,
koboLocationSource: 'OEBPS/ch1.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 25,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
}),
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Array),
set: expect.objectContaining({ cfi: 'epubcfi(/6/2)', pageNumber: 7, percentage: 80 }),
set: expect.objectContaining({
cfi: 'epubcfi(/6/2)',
pageNumber: 7,
percentage: 80,
koboLocationSource: 'OEBPS/ch1.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 25,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
}),
}),
);
});
it('syncs primary EPUB progress into Kobo reading state and marks snapshot row pending', async () => {
const insertChain = makeInsertChain();
const db = {
select: vi
.fn()
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
.mockReturnValueOnce(
makeSelectChain('limit', [
{
entitlementId: '10',
createdAtKobo: '2026-01-01T00:00:00.000Z',
currentBookmark: {
LastModified: '2026-01-01T00:00:00.000Z',
Location: { Source: 'old.xhtml', Type: 'KoboSpan', Value: 'kobo.1.1' },
ProgressPercent: 20,
ContentSourceProgressPercent: 2,
ChapterProgress: 2,
},
statistics: { LastModified: '2026-01-01T00:00:00.000Z' },
statusInfo: { LastModified: '2026-01-01T00:00:00.000Z', TimesStartedReading: 1 },
},
]),
),
insert: vi.fn().mockReturnValue(insertChain),
execute: vi.fn().mockResolvedValue(undefined),
};
const repo = new BookRepository(db as never);
await expect(repo.syncKoboReadingStateFromProgress(5, 9, 80, 'OEBPS/ch14.xhtml', 'KoboSpan', 'kobo.25.1', 33.5)).resolves.toBe(true);
expect(insertChain.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 5,
bookId: 10,
entitlementId: '10',
createdAtKobo: '2026-01-01T00:00:00.000Z',
currentBookmark: expect.objectContaining({
LastModified: expect.any(String),
ProgressPercent: 80,
ContentSourceProgressPercent: 33.5,
ChapterProgress: 2,
Location: { Source: 'OEBPS/ch14.xhtml', Type: 'KoboSpan', Value: 'kobo.25.1' },
}),
statusInfo: expect.objectContaining({
TimesStartedReading: 1,
Status: 'Reading',
}),
}),
);
expect(insertChain.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Array),
set: expect.objectContaining({
currentBookmark: expect.objectContaining({
LastModified: expect.any(String),
ProgressPercent: 80,
ContentSourceProgressPercent: 33.5,
ChapterProgress: 2,
Location: { Source: 'OEBPS/ch14.xhtml', Type: 'KoboSpan', Value: 'kobo.25.1' },
}),
statusInfo: expect.objectContaining({ Status: 'Reading' }),
}),
}),
);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('clears stale Kobo source-level percent while preserving device bookmark fields', async () => {
const insertChain = makeInsertChain();
const db = {
select: vi
.fn()
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
.mockReturnValueOnce(
makeSelectChain('limit', [
{
entitlementId: '10',
createdAtKobo: '2026-01-01T00:00:00.000Z',
currentBookmark: {
LastModified: '2026-01-01T00:00:00.000Z',
Location: { Source: 'old.xhtml', Type: 'KoboSpan', Value: 'kobo.1.1' },
ProgressPercent: 20,
ContentSourceProgressPercent: 2,
ChapterProgress: 2,
},
statistics: { LastModified: '2026-01-01T00:00:00.000Z' },
statusInfo: { LastModified: '2026-01-01T00:00:00.000Z' },
},
]),
),
insert: vi.fn().mockReturnValue(insertChain),
execute: vi.fn().mockResolvedValue(undefined),
};
const repo = new BookRepository(db as never);
await expect(repo.syncKoboReadingStateFromProgress(5, 9, 80, 'OEBPS/ch14.xhtml', 'KoboSpan', 'kobo.25.1', null)).resolves.toBe(true);
const insertedBookmark = insertChain.values.mock.calls[0][0].currentBookmark;
expect(insertedBookmark).toEqual(
expect.objectContaining({
LastModified: expect.any(String),
ProgressPercent: 80,
ChapterProgress: 2,
Location: { Source: 'OEBPS/ch14.xhtml', Type: 'KoboSpan', Value: 'kobo.25.1' },
}),
);
expect(insertedBookmark).not.toHaveProperty('ContentSourceProgressPercent');
});
it('does not overwrite Kobo reading state without an exact KoboSpan location', async () => {
const insertChain = makeInsertChain();
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }])),
insert: vi.fn().mockReturnValue(insertChain),
execute: vi.fn().mockResolvedValue(undefined),
};
const repo = new BookRepository(db as never);
await expect(repo.syncKoboReadingStateFromProgress(5, 9, 80, 'OEBPS/ch14.xhtml', null, null, 33.5)).resolves.toBe(false);
expect(db.select).toHaveBeenCalledTimes(1);
expect(db.insert).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
});
it('does not sync Kobo reading state for non-primary EPUB files', async () => {
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 99, format: 'epub' }])),
insert: vi.fn(),
execute: vi.fn(),
};
const repo = new BookRepository(db as never);
await expect(repo.syncKoboReadingStateFromProgress(5, 9, 80)).resolves.toBe(false);
expect(db.insert).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
});
it('reads whether Kobo two-way progress sync is enabled', async () => {
const db = {
select: vi
.fn()
.mockReturnValueOnce(makeSelectChain('limit', [{ twoWayProgressSync: true }]))
.mockReturnValueOnce(makeSelectChain('limit', [])),
};
const repo = new BookRepository(db as never);
await expect(repo.isKoboTwoWayProgressSyncEnabled(5)).resolves.toBe(true);
await expect(repo.isKoboTwoWayProgressSyncEnabled(6)).resolves.toBe(false);
});
it('clears both reading_progress and audiobook_progress rows for a file id', async () => {
const readingWhere = vi.fn().mockResolvedValue(undefined);
const audioWhere = vi.fn().mockResolvedValue(undefined);
+249 -2
View File
@@ -25,6 +25,7 @@ import {
koboLibrarySnapshots,
koboReadingStates,
koboSnapshotBooks,
koboSyncSettings,
libraries,
narrators,
audiobookProgress,
@@ -38,6 +39,7 @@ type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MetadataUpdateExecutor = Pick<Db, 'update' | 'insert'>;
type MetadataReadExecutor = Pick<Db, 'select'>;
type JsonObj = Record<string, unknown>;
type CollapsedRawRow = {
id: number;
@@ -84,6 +86,8 @@ type PatternMetadataRow = {
authors: string[];
};
const PROGRESS_EPSILON = 0.0001;
@Injectable()
export class BookRepository {
constructor(
@@ -611,6 +615,8 @@ export class BookRepository {
format: bookFiles.format,
bookId: bookFiles.bookId,
libraryId: books.libraryId,
fileHash: bookFiles.fileHash,
sizeBytes: bookFiles.sizeBytes,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
@@ -635,6 +641,11 @@ export class BookRepository {
cfi: readingProgress.cfi,
pageNumber: readingProgress.pageNumber,
percentage: readingProgress.percentage,
koboLocationSource: readingProgress.koboLocationSource,
koboLocationType: readingProgress.koboLocationType,
koboLocationValue: readingProgress.koboLocationValue,
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
koreaderProgress: readingProgress.koreaderProgress,
updatedAt: readingProgress.updatedAt,
})
.from(bookFiles)
@@ -1023,17 +1034,170 @@ export class BookRepository {
pageNumber: number | null,
percentage: number,
positionSeconds?: number | null,
koboLocationSource?: string | null,
koboLocationType?: string | null,
koboLocationValue?: string | null,
koboContentSourceProgressPercent?: number | null,
koreaderProgress?: string | null,
) {
const now = new Date();
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
const normalizedKoreaderProgress = this.normalizeKoreaderProgress(koreaderProgress);
await this.db
.insert(readingProgress)
.values({ userId, bookFileId: fileId, cfi, pageNumber, percentage, positionSeconds: positionSeconds ?? null, updatedAt: now })
.values({
userId,
bookFileId: fileId,
cfi,
pageNumber,
percentage,
positionSeconds: positionSeconds ?? null,
koboLocationSource: normalizedKoboLocationSource,
koboLocationType: normalizedKoboLocationType,
koboLocationValue: normalizedKoboLocationValue,
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
koreaderProgress: normalizedKoreaderProgress,
updatedAt: now,
})
.onConflictDoUpdate({
target: [readingProgress.bookFileId, readingProgress.userId],
set: { cfi, pageNumber, percentage, positionSeconds: positionSeconds ?? null, updatedAt: now },
set: {
cfi,
pageNumber,
percentage,
positionSeconds: positionSeconds ?? null,
koboLocationSource: normalizedKoboLocationSource,
koboLocationType: normalizedKoboLocationType,
koboLocationValue: normalizedKoboLocationValue,
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
koreaderProgress: normalizedKoreaderProgress,
updatedAt: now,
},
});
}
async syncKoboReadingStateFromProgress(
userId: number,
fileId: number,
percentage: number,
koboLocationSource?: string | null,
koboLocationType?: string | null,
koboLocationValue?: string | null,
koboContentSourceProgressPercent?: number | null,
): Promise<boolean> {
const [file] = await this.db
.select({
bookId: bookFiles.bookId,
primaryFileId: books.primaryFileId,
format: bookFiles.format,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.id, fileId))
.limit(1);
if (!file || file.primaryFileId !== fileId || file.format !== 'epub') return false;
const clampedPercentage = this.clampProgressPercentage(percentage);
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
if (!normalizedKoboLocationSource || normalizedKoboLocationType !== 'KoboSpan' || !normalizedKoboLocationValue) return false;
const now = new Date();
const nowIso = now.toISOString();
const [existing] = await this.db
.select({
entitlementId: koboReadingStates.entitlementId,
createdAtKobo: koboReadingStates.createdAtKobo,
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
})
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, file.bookId)))
.limit(1);
const existingBookmark = this.asJsonObj(existing?.currentBookmark);
if (
this.isKoboBookmarkCurrent(
existingBookmark,
clampedPercentage,
normalizedKoboLocationSource,
normalizedKoboLocationType,
normalizedKoboLocationValue,
normalizedKoboContentSourceProgressPercent,
)
) {
return true;
}
const currentBookmark: JsonObj = {
...(existingBookmark ?? {}),
LastModified: nowIso,
ProgressPercent: clampedPercentage,
Location: {
Source: normalizedKoboLocationSource,
Type: normalizedKoboLocationType,
Value: normalizedKoboLocationValue,
},
};
if (normalizedKoboContentSourceProgressPercent !== null) {
currentBookmark.ContentSourceProgressPercent = normalizedKoboContentSourceProgressPercent;
} else {
delete currentBookmark.ContentSourceProgressPercent;
}
const statusInfo = {
...(this.asJsonObj(existing?.statusInfo) ?? {}),
LastModified: nowIso,
Status: this.deriveKoboStatus(clampedPercentage),
};
const statistics = this.asJsonObj(existing?.statistics) ?? { LastModified: nowIso };
await this.db
.insert(koboReadingStates)
.values({
userId,
bookId: file.bookId,
entitlementId: existing?.entitlementId ?? String(file.bookId),
createdAtKobo: existing?.createdAtKobo ?? nowIso,
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark,
statistics,
statusInfo,
updatedAt: now,
})
.onConflictDoUpdate({
target: [koboReadingStates.userId, koboReadingStates.bookId],
set: {
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark,
statistics,
statusInfo,
updatedAt: now,
},
});
await this.markKoboSnapshotBookUnsyncedForReadingState(userId, file.bookId);
return true;
}
async isKoboTwoWayProgressSyncEnabled(userId: number): Promise<boolean> {
const [settings] = await this.db
.select({ twoWayProgressSync: koboSyncSettings.twoWayProgressSync })
.from(koboSyncSettings)
.where(eq(koboSyncSettings.userId, userId))
.limit(1);
return settings?.twoWayProgressSync === true;
}
async clearFileProgress(userId: number, fileId: number): Promise<void> {
await this.db.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
await this.db.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
@@ -1060,4 +1224,87 @@ export class BookRepository {
.returning();
return row;
}
private clampProgressPercentage(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
private clampNullableProgressPercentage(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? this.clampProgressPercentage(value) : null;
}
private normalizeKoboLocationPart(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private normalizeKoreaderProgress(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.startsWith('/body/DocFragment[') ? trimmed : null;
}
private asJsonObj(value: unknown): JsonObj | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as JsonObj;
}
private extractKoboPercent(bookmark: JsonObj | null): number | null {
const candidate = bookmark?.ProgressPercent;
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
}
private extractKoboContentSourceProgressPercent(bookmark: JsonObj | null): number | null {
const candidate = bookmark?.ContentSourceProgressPercent;
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
}
private isKoboBookmarkCurrent(
bookmark: JsonObj | null,
percentage: number,
koboLocationSource: string | null,
koboLocationType: string | null,
koboLocationValue: string | null,
koboContentSourceProgressPercent: number | null,
): boolean {
const existingPercent = this.extractKoboPercent(bookmark);
if (existingPercent === null || Math.abs(existingPercent - percentage) >= PROGRESS_EPSILON) return false;
if (!koboLocationSource || !koboLocationType || !koboLocationValue) return !this.hasNonInternalBookmarkFields(bookmark);
const location = this.asJsonObj(bookmark?.Location);
if (location?.Source !== koboLocationSource || location.Type !== koboLocationType || location.Value !== koboLocationValue) return false;
if (koboContentSourceProgressPercent === null) {
return this.extractKoboContentSourceProgressPercent(bookmark) === null;
}
const existingSourcePercent = this.extractKoboContentSourceProgressPercent(bookmark);
return existingSourcePercent !== null && Math.abs(existingSourcePercent - koboContentSourceProgressPercent) < PROGRESS_EPSILON;
}
private hasNonInternalBookmarkFields(bookmark: JsonObj | null): boolean {
if (!bookmark) return false;
return Object.keys(bookmark).some((key) => key !== 'LastModified' && key !== 'ProgressPercent');
}
private deriveKoboStatus(percentage: number): string {
if (percentage >= 100) return 'Finished';
return percentage > 0 ? 'Reading' : 'ReadyToRead';
}
private async markKoboSnapshotBookUnsyncedForReadingState(userId: number, bookId: number): Promise<void> {
await this.db.execute(sql`
UPDATE ${koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
}
+104 -3
View File
@@ -3,7 +3,7 @@ import type { MockedFunction } from 'vitest';
import { access, readdir, rm, stat } from 'fs/promises';
import type { RequestUser } from '../../common/types/request-user';
import { AUDIO_BOOK_FILE_WRITE_FIELDS, MetadataProviderKey, type BookQuery } from '@bookorbit/types';
import { AUDIO_BOOK_FILE_WRITE_FIELDS, MetadataProviderKey, Permission, type BookQuery } from '@bookorbit/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
@@ -111,6 +111,8 @@ function makeService() {
findProgress: vi.fn(),
findProgressByBook: vi.fn(),
upsertProgress: vi.fn(),
syncKoboReadingStateFromProgress: vi.fn(),
isKoboTwoWayProgressSyncEnabled: vi.fn().mockResolvedValue(false),
clearFileProgress: vi.fn(),
findAudioProgress: vi.fn(),
upsertAudioProgress: vi.fn(),
@@ -1510,6 +1512,26 @@ describe('BookService', () => {
expect(result.snapshot?.snapshotId).toBe(99);
});
it('ignores source-level Kobo progress in book state summaries', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ permissions: ['kobo_sync'] } as never);
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findKoboReadingState.mockResolvedValue({
currentBookmark: { ContentSourceProgressPercent: 75 },
statusInfo: { Status: 'Reading' },
createdAtKobo: 'created',
lastModifiedKobo: 'updated',
priorityTimestamp: 'priority',
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
bookRepo.findKoboSnapshotState.mockResolvedValue(null);
bookRepo.findKoboSyncCollectionNamesForBook.mockResolvedValue(['Favorites']);
const result = await service.getKoboState(10, user);
expect(result.readingState?.progressPercent).toBeNull();
});
it('bulkReExtractCover reports progress for every processed book file, including unchanged covers', async () => {
const { service, bookRepo, libraryService, metadataService } = makeService();
const user = makeUser();
@@ -1692,6 +1714,11 @@ describe('BookService', () => {
cfi: null,
pageNumber: null,
percentage: null,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
{
@@ -1699,6 +1726,11 @@ describe('BookService', () => {
cfi: 'epubcfi(/6/4)',
pageNumber: 12,
percentage: 45,
koboLocationSource: 'OEBPS/chapter.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 22,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
updatedAt: new Date('2026-01-04T00:00:00.000Z'),
},
]);
@@ -1712,6 +1744,11 @@ describe('BookService', () => {
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
{
@@ -1719,6 +1756,11 @@ describe('BookService', () => {
cfi: 'epubcfi(/6/4)',
pageNumber: 12,
percentage: 45,
koboLocationSource: 'OEBPS/chapter.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 22,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
updatedAt: new Date('2026-01-04T00:00:00.000Z'),
},
]);
@@ -1737,7 +1779,7 @@ describe('BookService', () => {
await service.saveProgress(user.id, 7, { percentage: 25, positionSeconds: 900 } as never, user);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 7, null, null, 25, 900);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 7, null, null, 25, 900, null, null, null, null, null);
});
it('passes null positionSeconds when not provided in DTO', async () => {
@@ -1751,7 +1793,66 @@ describe('BookService', () => {
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null, null, null, null, null, null);
});
it('mirrors EPUB percentage to Kobo state for users with Kobo sync permission', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(true);
bookRepo.syncKoboReadingStateFromProgress.mockResolvedValue(true);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(
user.id,
8,
{
percentage: 50,
cfi: 'epubcfi(/6/2)',
koboLocationSource: 'OEBPS/ch1.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 25,
} as never,
user,
);
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(user.id, 8, 50, 'OEBPS/ch1.xhtml', 'KoboSpan', 'kobo.25.1', 25);
});
it('does not mirror EPUB percentage to Kobo state when two-way sync is disabled', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.isKoboTwoWayProgressSyncEnabled).toHaveBeenCalledWith(user.id);
expect(bookRepo.syncKoboReadingStateFromProgress).not.toHaveBeenCalled();
});
it('does not mirror EPUB percentage to Kobo state without Kobo sync permission', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.isKoboTwoWayProgressSyncEnabled).not.toHaveBeenCalled();
expect(bookRepo.syncKoboReadingStateFromProgress).not.toHaveBeenCalled();
});
});
+30 -2
View File
@@ -1504,6 +1504,11 @@ export class BookService {
cfi: row.cfi ?? null,
pageNumber: row.pageNumber ?? null,
percentage: row.percentage ?? 0,
koboLocationSource: row.koboLocationSource ?? null,
koboLocationType: row.koboLocationType ?? null,
koboLocationValue: row.koboLocationValue ?? null,
koboContentSourceProgressPercent: row.koboContentSourceProgressPercent ?? null,
koreaderProgress: row.koreaderProgress ?? null,
updatedAt: row.updatedAt ?? null,
}));
}
@@ -1530,7 +1535,30 @@ export class BookService {
async saveProgress(userId: number, fileId: number, dto: SaveProgressDto, user: RequestUser) {
const file = await this.verifyFileAccess(fileId, user);
await this.bookRepo.upsertProgress(userId, fileId, dto.cfi ?? null, dto.pageNumber ?? null, dto.percentage, dto.positionSeconds ?? null);
await this.bookRepo.upsertProgress(
userId,
fileId,
dto.cfi ?? null,
dto.pageNumber ?? null,
dto.percentage,
dto.positionSeconds ?? null,
dto.koboLocationSource ?? null,
dto.koboLocationType ?? null,
dto.koboLocationValue ?? null,
dto.koboContentSourceProgressPercent ?? null,
dto.koreaderProgress ?? null,
);
if (file.format === 'epub' && this.hasPermission(user, Permission.KoboSync) && (await this.bookRepo.isKoboTwoWayProgressSyncEnabled(userId))) {
await this.bookRepo.syncKoboReadingStateFromProgress(
userId,
fileId,
dto.percentage,
dto.koboLocationSource ?? null,
dto.koboLocationType ?? null,
dto.koboLocationValue ?? null,
dto.koboContentSourceProgressPercent ?? null,
);
}
this.libraryService
.findOne(file.libraryId)
.then((lib) =>
@@ -1975,7 +2003,7 @@ export class BookService {
const currentBookmark = (readingStateRow?.currentBookmark ?? null) as Record<string, unknown> | null;
const statusInfo = (readingStateRow?.statusInfo ?? null) as Record<string, unknown> | null;
const progressCandidate = currentBookmark?.ProgressPercent ?? currentBookmark?.ContentSourceProgressPercent;
const progressCandidate = currentBookmark?.ProgressPercent;
const progressPercent = typeof progressCandidate === 'number' ? Math.max(0, Math.min(100, progressCandidate)) : null;
const status = typeof statusInfo?.Status === 'string' ? statusInfo.Status : null;
@@ -121,11 +121,21 @@ describe('Book DTO validation', () => {
it('validates SaveProgressDto percentage bounds and conditional field types', async () => {
expect((await errorsFor(SaveProgressDto, { percentage: 0 })).length).toBe(0);
expect((await errorsFor(SaveProgressDto, { percentage: 100, cfi: 'epubcfi(/6/2)', pageNumber: 5 })).length).toBe(0);
expect(
(
await errorsFor(SaveProgressDto, {
percentage: 100,
cfi: 'epubcfi(/6/2)',
pageNumber: 5,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
})
).length,
).toBe(0);
expect((await errorsFor(SaveProgressDto, { percentage: -1 })).length).toBeGreaterThan(0);
expect((await errorsFor(SaveProgressDto, { percentage: 50, cfi: 123 })).length).toBeGreaterThan(0);
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 'five' })).length).toBeGreaterThan(0);
expect((await errorsFor(SaveProgressDto, { percentage: 50, koreaderProgress: 123 })).length).toBeGreaterThan(0);
});
it('requires non-empty search text and bounds search limit', async () => {
@@ -19,4 +19,27 @@ export class SaveProgressDto {
@IsNumber()
@Min(0)
positionSeconds?: number | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationSource != null)
@IsString()
koboLocationSource?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationType != null)
@IsString()
koboLocationType?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koboLocationValue != null)
@IsString()
koboLocationValue?: string | null;
@ValidateIf((o: SaveProgressDto) => o.koreaderProgress != null)
@IsString()
koreaderProgress?: string | null;
@IsOptional()
@ValidateIf((o: SaveProgressDto) => o.koboContentSourceProgressPercent != null)
@IsNumber()
@Min(0)
@Max(100)
koboContentSourceProgressPercent?: number | null;
}
@@ -1,7 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class KoboAuthDeviceBodyDto {
@IsString()
@IsOptional()
UserKey?: string;
}
@@ -1,7 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class KoboAuthRefreshBodyDto {
@IsString()
@IsOptional()
RefreshToken?: string;
}
@@ -27,6 +27,7 @@ describe('Kobo DTO validation', () => {
await getErrors(UpdateSettingsDto, {
convertToKepub: true,
forceEnableHyphenation: false,
twoWayProgressSync: true,
kepubConversionLimitMb: 120,
})
).length,
@@ -34,6 +35,7 @@ describe('Kobo DTO validation', () => {
expect((await getErrors(UpdateSettingsDto, { convertToKepub: 'true' })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { forceEnableHyphenation: 'false' })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { twoWayProgressSync: 'true' })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 0 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 501 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 10.5 })).length).toBeGreaterThan(0);
@@ -21,6 +21,10 @@ export class UpdateSettingsDto {
@IsBoolean()
forceEnableHyphenation?: boolean;
@IsOptional()
@IsBoolean()
twoWayProgressSync?: boolean;
@IsOptional()
@IsInt()
@Min(1)
@@ -1,10 +1,21 @@
import { ValidationPipe } from '@nestjs/common';
import { KoboAuthController } from './kobo-auth.controller';
describe('KoboAuthController', () => {
const controller = new KoboAuthController();
const nativeKoboAuthPayload = {
AffiliateName: 'Kobo',
AppVersion: '4.45.23697',
ClientKey: 'client-key',
DeviceId: 'device-id',
PlatformId: 'platform-id',
SerialNumber: 'serial-number',
UserKey: 'user-key-1',
};
it('authDevice returns fresh tokens and echoes UserKey when present', () => {
const result = controller.authDevice({ UserKey: 'user-key-1' });
const result = controller.authDevice(nativeKoboAuthPayload);
expect(result.AccessToken).toEqual(expect.any(String));
expect(result.RefreshToken).toEqual(expect.any(String));
@@ -20,8 +31,15 @@ describe('KoboAuthController', () => {
expect(result.RefreshToken).toEqual(expect.any(String));
});
it('authDevice falls back to empty UserKey when body is null', () => {
const result = controller.authDevice(null);
expect(result.UserKey).toBe('');
expect(result.RefreshToken).toEqual(expect.any(String));
});
it('authRefresh preserves incoming RefreshToken when provided', () => {
const result = controller.authRefresh({ RefreshToken: 'existing-refresh' });
const result = controller.authRefresh({ ...nativeKoboAuthPayload, RefreshToken: 'existing-refresh' });
expect(result.AccessToken).toEqual(expect.any(String));
expect(result.RefreshToken).toBe('existing-refresh');
@@ -35,4 +53,27 @@ describe('KoboAuthController', () => {
expect(result.RefreshToken).toEqual(expect.any(String));
expect(result.AccessToken).toEqual(expect.any(String));
});
it('authRefresh generates RefreshToken when body is null', () => {
const result = controller.authRefresh(null);
expect(result.RefreshToken).toEqual(expect.any(String));
expect(result.AccessToken).toEqual(expect.any(String));
});
it('keeps auth bodies compatible with the global whitelist validation pipe', async () => {
const authDeviceParamTypes = Reflect.getMetadata('design:paramtypes', KoboAuthController.prototype, 'authDevice') as unknown[];
const authRefreshParamTypes = Reflect.getMetadata('design:paramtypes', KoboAuthController.prototype, 'authRefresh') as unknown[];
const globalPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
expect(authDeviceParamTypes[0]).toBe(Object);
expect(authRefreshParamTypes[0]).toBe(Object);
await expect(globalPipe.transform(nativeKoboAuthPayload, { type: 'body', metatype: Object, data: undefined })).resolves.toEqual(
nativeKoboAuthPayload,
);
});
});
@@ -3,8 +3,16 @@ import { randomUUID } from 'crypto';
import { Public } from '../../common/decorators/public.decorator';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KoboAuthDeviceBodyDto } from './dto/kobo-auth-device-body.dto';
import { KoboAuthRefreshBodyDto } from './dto/kobo-auth-refresh-body.dto';
type KoboAuthBody = Record<string, unknown>;
function asAuthBody(value: unknown): KoboAuthBody {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as KoboAuthBody) : {};
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
@Controller('kobo/:deviceToken')
@Public()
@@ -12,22 +20,24 @@ import { KoboAuthRefreshBodyDto } from './dto/kobo-auth-refresh-body.dto';
export class KoboAuthController {
@Post('v1/auth/device')
@HttpCode(HttpStatus.OK)
authDevice(@Body() body: KoboAuthDeviceBodyDto) {
authDevice(@Body() body: unknown = {}) {
const payload = asAuthBody(body);
return {
AccessToken: randomUUID(),
RefreshToken: randomUUID(),
TokenType: 'Bearer',
TrackingId: randomUUID(),
UserKey: body.UserKey ?? '',
UserKey: optionalString(payload.UserKey) ?? '',
};
}
@Post('v1/auth/refresh')
@HttpCode(HttpStatus.OK)
authRefresh(@Body() body: KoboAuthRefreshBodyDto) {
authRefresh(@Body() body: unknown = {}) {
const payload = asAuthBody(body);
return {
AccessToken: randomUUID(),
RefreshToken: body.RefreshToken ?? randomUUID(),
RefreshToken: optionalString(payload.RefreshToken) ?? randomUUID(),
TokenType: 'Bearer',
TrackingId: randomUUID(),
};
@@ -19,14 +19,28 @@ describe('KoboDeviceController', () => {
const proxyService = {
forward: vi.fn(),
};
const bookIdentityService = {
resolveBookIdByEntitlementId: vi.fn(),
resolveBookIdByCoverImageId: vi.fn(),
};
const controller = new KoboDeviceController(thumbnailService as never, downloadService as never, proxyService as never);
const controller = new KoboDeviceController(
thumbnailService as never,
downloadService as never,
proxyService as never,
bookIdentityService as never,
);
beforeEach(() => {
vi.clearAllMocks();
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
bookIdentityService.resolveBookIdByCoverImageId.mockImplementation((_userId: number, id: string) => {
const match = /^(\d+)(?:-\d+)?$/.exec(id);
return match ? Number(match[1]) : null;
});
});
it('parses compound CoverImageId format (bookId-timestamp) correctly for thumbnails and downloads', async () => {
it('resolves legacy compound CoverImageId format (bookId-timestamp) for thumbnails', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/42-1778450221186/thumbnail/355/530/80/False/image.jpg' };
const reply = makeReply();
@@ -46,11 +60,9 @@ describe('KoboDeviceController', () => {
req as never,
reply as never,
);
await controller.download('42-1778450221186', { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(1, 5, 42, undefined, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(2, 5, 42, undefined, reply);
expect(downloadService.streamBook).toHaveBeenCalledWith(5, 42, reply);
});
it('serves thumbnail for valid book ids across all thumbnail endpoints', async () => {
@@ -71,6 +83,8 @@ describe('KoboDeviceController', () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/not-a-number/download' };
const reply = makeReply();
proxyService.forward.mockResolvedValue(undefined);
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(null);
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(null);
await controller.thumbnailSimple('abc', undefined, { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
await controller.download('abc', { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
@@ -89,6 +103,26 @@ describe('KoboDeviceController', () => {
expect(downloadService.streamBook).toHaveBeenCalledWith(11, 9, reply);
});
it('streams downloads and thumbnails for resolved Kobo UUID ids', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/kobo-id/download' };
const reply = makeReply();
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(420);
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(420);
await controller.download('kobo-entitlement-id', { id: 11 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
await controller.thumbnailSimple(
'kobo-cover-id',
undefined,
{ id: 11 } as never,
{ deviceToken: 'dev-token' } as never,
req as never,
reply as never,
);
expect(downloadService.streamBook).toHaveBeenCalledWith(11, 420, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenCalledWith(11, 420, undefined, reply);
});
it('returns static payload endpoints and analytics keys', () => {
expect(controller.affiliate()).toEqual({});
expect(controller.remainingBookSeries()).toEqual({ TotalResultCount: 0, SearchResults: [] });
@@ -11,6 +11,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
import { KoboDownloadService } from './services/kobo-download.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
@Controller('kobo/:deviceToken')
@Public()
@@ -22,6 +23,7 @@ export class KoboDeviceController {
private readonly thumbnailService: KoboThumbnailService,
private readonly downloadService: KoboDownloadService,
private readonly proxyService: KoboProxyService,
private readonly bookIdentityService: KoboBookIdentityService,
) {}
@Get('v1/books/:bookId/thumbnail/:width/:height/:quality/:isGreyscale/image.jpg')
@@ -68,8 +70,8 @@ export class KoboDeviceController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
await this.downloadService.streamBook(user.id, id, reply);
}
@@ -117,8 +119,8 @@ export class KoboDeviceController {
req: FastifyRequest,
reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByCoverImageId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
await this.thumbnailService.serveThumbnail(user.id, id, ifNoneMatch, reply);
}
}
@@ -26,11 +26,21 @@ describe('KoboSyncController', () => {
const proxyService = {
forward: vi.fn(),
};
const bookIdentityService = {
resolveBookIdByEntitlementId: vi.fn(),
};
const controller = new KoboSyncController(settingsService as never, syncService as never, readingStateService as never, proxyService as never);
const controller = new KoboSyncController(
settingsService as never,
syncService as never,
readingStateService as never,
proxyService as never,
bookIdentityService as never,
);
beforeEach(() => {
vi.clearAllMocks();
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) => (/^\d+$/.test(id) ? Number(id) : null));
});
it('initialization builds resource URLs from forwarded headers', () => {
@@ -182,9 +192,31 @@ describe('KoboSyncController', () => {
expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]);
});
it('acknowledges delete-items requests for synced BookOrbit collection tags', async () => {
const req = { method: 'POST', url: '/api/v1/kobo/token/v1/library/tags/col-1/items/delete' };
const reply = makeReply();
await controller.deleteTagItems('col-1', { deviceToken: 'token-1' } as never, req as never, reply as never);
expect(reply.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' });
expect(proxyService.forward).not.toHaveBeenCalled();
});
it('proxies delete-items requests for non-BookOrbit Kobo tags', async () => {
const req = { method: 'POST', url: '/api/v1/kobo/token/v1/library/tags/kobo-tag/items/delete' };
const reply = makeReply();
await controller.deleteTagItems('kobo-tag', { deviceToken: 'token-1' } as never, req as never, reply as never);
expect(proxyService.forward).toHaveBeenCalledWith(req, reply, 'token-1');
expect(reply.send).not.toHaveBeenCalled();
});
it('proxies metadata/state/delete for non-numeric book ids', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/library/abc/metadata' };
const reply = makeReply();
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(null);
await controller.getBookMetadata('abc', { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never);
await controller.deleteFromLibrary('abc', { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never);
@@ -215,6 +247,18 @@ describe('KoboSyncController', () => {
expect(reply.send).toHaveBeenNthCalledWith(4, [{ EntitlementId: '5' }]);
});
it('resolves Kobo UUID entitlement ids before calling services', async () => {
const req = { headers: { host: 'localhost:3000' }, protocol: 'http', hostname: 'localhost', socket: { localPort: 3000 } };
const reply = makeReply();
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(420);
syncService.getBookMetadata.mockResolvedValue([{ Title: 'Mapped' }]);
await controller.getBookMetadata('a-kobo-uuid', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never);
expect(syncService.getBookMetadata).toHaveBeenCalledWith(4, 420, 'token-5', 'http://localhost:3000');
expect(reply.send).toHaveBeenCalledWith([{ Title: 'Mapped' }]);
});
it('updateReadingState uses first ReadingStates element when provided', async () => {
settingsService.getSettings.mockResolvedValue({
readingThreshold: 1,
@@ -222,6 +266,7 @@ describe('KoboSyncController', () => {
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
});
readingStateService.upsertState.mockResolvedValue({ RequestResult: 'Success' });
const reply = makeReply();
@@ -238,7 +283,14 @@ describe('KoboSyncController', () => {
reply as never,
);
expect(readingStateService.upsertState).toHaveBeenCalledWith(8, 77, { EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } }, 1, 99);
expect(readingStateService.upsertState).toHaveBeenCalledWith(
8,
77,
{ EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } },
1,
99,
true,
);
expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' });
});
});
@@ -9,6 +9,7 @@ import {
HttpStatus,
Logger,
Param,
Post,
Put,
Req,
Res,
@@ -29,6 +30,7 @@ import { KoboSyncService } from './services/kobo-sync.service';
import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
function buildBaseUrl(req: FastifyRequest): string {
const fwdHost = req.headers['x-forwarded-host'];
@@ -65,6 +67,7 @@ export class KoboSyncController {
private readonly syncService: KoboSyncService,
private readonly readingStateService: KoboReadingStateService,
private readonly proxyService: KoboProxyService,
private readonly bookIdentityService: KoboBookIdentityService,
) {}
@Get('v1/initialization')
@@ -99,6 +102,18 @@ export class KoboSyncController {
reply.send(entitlements);
}
@Post('v1/library/tags/:tagId/items/delete')
@HttpCode(HttpStatus.OK)
async deleteTagItems(
@Param('tagId') tagId: string,
@KoboDevice() device: KoboDeviceContext,
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
if (!tagId.startsWith('col-')) return this.proxyService.forward(req, reply, device.deviceToken);
reply.status(HttpStatus.OK).send({ RequestResult: 'Success' });
}
@Get('v1/library/:bookId/metadata')
async getBookMetadata(
@Param('bookId') bookId: string,
@@ -107,8 +122,8 @@ export class KoboSyncController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
const baseUrl = buildBaseUrl(req);
const metadata = await this.syncService.getBookMetadata(user.id, id, device.deviceToken, baseUrl);
reply.send(metadata);
@@ -123,8 +138,8 @@ export class KoboSyncController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
await this.syncService.removeBookFromSync(user.id, id);
reply.status(HttpStatus.OK).send();
}
@@ -137,8 +152,8 @@ export class KoboSyncController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
const state = await this.readingStateService.getRawState(user.id, id);
reply.send(state ? [state] : []);
}
@@ -153,12 +168,19 @@ export class KoboSyncController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
const settings = await this.settingsService.getSettings(user.id);
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
const statePayload = states?.[0] ?? body;
const result = await this.readingStateService.upsertState(user.id, id, statePayload, settings.readingThreshold, settings.finishedThreshold);
const result = await this.readingStateService.upsertState(
user.id,
id,
statePayload,
settings.readingThreshold,
settings.finishedThreshold,
settings.twoWayProgressSync,
);
reply.send(result);
}
}
@@ -40,22 +40,34 @@ describe('KoboUserController', () => {
});
it('delegates settings reads and writes with current user id', async () => {
settingsService.getSettings.mockResolvedValue({ convertToKepub: true, forceEnableHyphenation: false, kepubConversionLimitMb: 100 });
settingsService.updateSettings.mockResolvedValue({ convertToKepub: false, forceEnableHyphenation: true, kepubConversionLimitMb: 150 });
settingsService.getSettings.mockResolvedValue({
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
settingsService.updateSettings.mockResolvedValue({
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 150,
twoWayProgressSync: true,
});
await expect(controller.getSettings({ id: 5 } as never)).resolves.toEqual({
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
await expect(controller.updateSettings({ convertToKepub: false } as never, { id: 5 } as never)).resolves.toEqual({
convertToKepub: false,
await expect(controller.updateSettings({ convertToKepub: false, twoWayProgressSync: true } as never, { id: 5 } as never)).resolves.toEqual({
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 150,
twoWayProgressSync: true,
});
expect(settingsService.getSettings).toHaveBeenCalledWith(5);
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { convertToKepub: false });
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { convertToKepub: false, twoWayProgressSync: true });
});
it('registers auditable metadata for create/rename/remove actions', () => {
@@ -8,8 +8,10 @@ import { KoboModule } from './kobo.module';
import { KoboSyncController } from './kobo-sync.controller';
import { KoboUserController } from './kobo-user.controller';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KepubConversionService } from './services/kepub-conversion.service';
import { KepubifyBinaryService } from './services/kepubify-binary.service';
import { KoboBookAccessService } from './services/kobo-book-access.service';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboDeviceService } from './services/kobo-device.service';
import { KoboDownloadService } from './services/kobo-download.service';
import { KoboProxyService } from './services/kobo-proxy.service';
@@ -27,14 +29,17 @@ describe('KoboModule', () => {
expect(providers).toEqual([
KoboTokenGuard,
KepubifyBinaryService,
KepubConversionService,
KoboDeviceService,
KoboSettingsService,
KoboBookAccessService,
KoboSyncService,
KoboBookIdentityService,
KoboReadingStateService,
KoboThumbnailService,
KoboDownloadService,
KoboProxyService,
]);
expect(Reflect.getMetadata(MODULE_METADATA.EXPORTS, KoboModule)).toEqual([KepubConversionService, KoboSettingsService]);
});
});
+5
View File
@@ -8,11 +8,13 @@ import { KoboDeviceController } from './kobo-device.controller';
import { KoboSyncController } from './kobo-sync.controller';
import { KoboUserController } from './kobo-user.controller';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KepubConversionService } from './services/kepub-conversion.service';
import { KoboBookAccessService } from './services/kobo-book-access.service';
import { KepubifyBinaryService } from './services/kepubify-binary.service';
import { KoboDeviceService } from './services/kobo-device.service';
import { KoboDownloadService } from './services/kobo-download.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboSettingsService } from './services/kobo-settings.service';
import { KoboSyncService } from './services/kobo-sync.service';
@@ -24,14 +26,17 @@ import { KoboThumbnailService } from './services/kobo-thumbnail.service';
providers: [
KoboTokenGuard,
KepubifyBinaryService,
KepubConversionService,
KoboDeviceService,
KoboSettingsService,
KoboBookAccessService,
KoboSyncService,
KoboBookIdentityService,
KoboReadingStateService,
KoboThumbnailService,
KoboDownloadService,
KoboProxyService,
],
exports: [KepubConversionService, KoboSettingsService],
})
export class KoboModule {}
@@ -0,0 +1,82 @@
vi.mock('fs/promises', () => ({
mkdir: vi.fn(),
stat: vi.fn(),
}));
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
import { execFile } from 'child_process';
import { mkdir, stat } from 'fs/promises';
import { KepubConversionService } from './kepub-conversion.service';
const statMock = vi.mocked(stat);
const mkdirMock = vi.mocked(mkdir);
const execFileMock = vi.mocked(execFile);
function makeService() {
const config = { get: vi.fn().mockReturnValue('/app-data') };
const kepubifyBinaryService = { getBinaryPath: vi.fn().mockResolvedValue('/tools/kepubify') };
return { service: new KepubConversionService(config as never, kepubifyBinaryService as never), kepubifyBinaryService };
}
describe('KepubConversionService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns an existing cached kepub path without running kepubify', async () => {
const { service, kepubifyBinaryService } = makeService();
statMock.mockResolvedValueOnce({} as never);
await expect(service.getKepubPath({ sourcePath: '/books/source.epub', fileHash: 'abc', bookId: 44, hyphenate: false })).resolves.toBe(
'/app-data/.kepub-cache/44/abc.kepub.epub',
);
expect(kepubifyBinaryService.getBinaryPath).not.toHaveBeenCalled();
expect(execFileMock).not.toHaveBeenCalled();
});
it('converts and caches a kepub when the cache is missing', async () => {
const { service } = makeService();
statMock.mockRejectedValueOnce(new Error('cache miss'));
execFileMock.mockImplementation((_path, _args, _options, cb) => {
cb?.(null, '', '');
return {} as never;
});
await expect(service.getKepubPath({ sourcePath: '/books/source.epub', fileHash: 'hash', bookId: 44, hyphenate: true })).resolves.toBe(
'/app-data/.kepub-cache/44/hash-hyph.kepub.epub',
);
expect(mkdirMock).toHaveBeenCalledWith('/app-data/.kepub-cache/44', { recursive: true });
expect(execFileMock).toHaveBeenCalledWith(
'/tools/kepubify',
['--hyphenate', '--output', '/app-data/.kepub-cache/44/hash-hyph.kepub.epub', '/books/source.epub'],
{ timeout: 60_000 },
expect.any(Function),
);
});
it('uses a stable nohash cache key when file hash is unavailable', async () => {
const { service } = makeService();
statMock.mockRejectedValueOnce(new Error('cache miss'));
execFileMock.mockImplementation((_path, _args, _options, cb) => {
cb?.(null, '', '');
return {} as never;
});
await expect(service.getKepubPath({ sourcePath: '/books/source.epub', fileHash: null, bookId: 44, hyphenate: false })).resolves.toBe(
'/app-data/.kepub-cache/44/nohash.kepub.epub',
);
expect(execFileMock).toHaveBeenCalledWith(
'/tools/kepubify',
['--output', '/app-data/.kepub-cache/44/nohash.kepub.epub', '/books/source.epub'],
{ timeout: 60_000 },
expect.any(Function),
);
});
});
@@ -0,0 +1,51 @@
import { execFile } from 'child_process';
import { mkdir, stat } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { KepubifyBinaryService } from './kepubify-binary.service';
const execFileAsync = promisify(execFile);
const KEPUBIFY_TIMEOUT_MS = 60_000;
interface KepubConversionInput {
sourcePath: string;
fileHash?: string | null;
bookId: number;
hyphenate: boolean;
}
@Injectable()
export class KepubConversionService {
private readonly kepubCachePath: string;
constructor(
private readonly config: ConfigService,
private readonly kepubifyBinaryService: KepubifyBinaryService,
) {
this.kepubCachePath = join(this.config.get<string>('storage.appDataPath')!, '.kepub-cache');
}
async getKepubPath(input: KepubConversionInput): Promise<string> {
const cacheDir = join(this.kepubCachePath, String(input.bookId));
const fileHash = input.fileHash ?? 'nohash';
const cacheKey = input.hyphenate ? `${fileHash}-hyph` : fileHash;
const cachedPath = join(cacheDir, `${cacheKey}.kepub.epub`);
try {
await stat(cachedPath);
return cachedPath;
} catch {
// Cache miss.
}
const binaryPath = await this.kepubifyBinaryService.getBinaryPath();
await mkdir(cacheDir, { recursive: true });
const args = input.hyphenate ? ['--hyphenate', '--output', cachedPath, input.sourcePath] : ['--output', cachedPath, input.sourcePath];
await execFileAsync(binaryPath, args, { timeout: KEPUBIFY_TIMEOUT_MS });
return cachedPath;
}
}
@@ -0,0 +1,111 @@
import { KoboBookIdentityService } from './kobo-book-identity.service';
type QueueState = {
select: unknown[];
insert: unknown[];
update: unknown[];
};
function makeChain(result: unknown) {
const chain: Record<string, unknown> = {
from: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
values: vi.fn(),
onConflictDoNothing: vi.fn(),
set: vi.fn(),
then: (onFulfilled: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) =>
Promise.resolve(result).then(onFulfilled, onRejected),
catch: (onRejected: (error: unknown) => unknown) => Promise.resolve(result).catch(onRejected),
};
for (const key of ['from', 'where', 'limit', 'values', 'onConflictDoNothing', 'set']) {
(chain[key] as ReturnType<typeof vi.fn>).mockReturnValue(chain);
}
return chain;
}
function makeDb(state?: Partial<QueueState>) {
const queue: QueueState = {
select: [...(state?.select ?? [])],
insert: [...(state?.insert ?? [])],
update: [...(state?.update ?? [])],
};
return {
select: vi.fn(() => makeChain(queue.select.shift() ?? [])),
insert: vi.fn(() => makeChain(queue.insert.shift() ?? [])),
update: vi.fn(() => makeChain(queue.update.shift() ?? [])),
};
}
describe('KoboBookIdentityService', () => {
const identity = {
bookId: 12,
entitlementId: '11111111-1111-4111-8111-111111111111',
coverImageId: '22222222-2222-4222-8222-222222222222',
needsLegacyNumericRemoval: true,
};
it('returns existing identities without inserting', async () => {
const db = makeDb({ select: [[identity]] });
const service = new KoboBookIdentityService(db as never);
await expect(service.ensureForBooks(5, [12, 12], true)).resolves.toEqual(new Map([[12, identity]]));
expect(db.insert).not.toHaveBeenCalled();
});
it('creates missing identities with the requested legacy-removal flag', async () => {
const db = makeDb({ select: [[], [identity]] });
const service = new KoboBookIdentityService(db as never);
const result = await service.ensureForBooks(5, [12], true);
expect(result.get(12)).toEqual(identity);
const insertChain = db.insert.mock.results[0].value as { values: ReturnType<typeof vi.fn> };
expect(insertChain.values).toHaveBeenCalledWith([{ userId: 5, bookId: 12, needsLegacyNumericRemoval: true }]);
});
it('resolves entitlement ids from UUID mapping and legacy numeric ids', async () => {
const db = makeDb({ select: [[{ bookId: 12 }]] });
const service = new KoboBookIdentityService(db as never);
await expect(service.resolveBookIdByEntitlementId(5, identity.entitlementId)).resolves.toBe(12);
await expect(service.resolveBookIdByEntitlementId(5, '420')).resolves.toBe(420);
await expect(service.resolveBookIdByEntitlementId(5, 'not-a-kobo-id')).resolves.toBeNull();
});
it('resolves cover image ids from UUID mapping, versioned UUIDs, and legacy compound ids', async () => {
const db = makeDb({ select: [[{ bookId: 12 }], [{ bookId: 12 }]] });
const service = new KoboBookIdentityService(db as never);
await expect(service.resolveBookIdByCoverImageId(5, identity.coverImageId)).resolves.toBe(12);
await expect(service.resolveBookIdByCoverImageId(5, `${identity.coverImageId}_1778450221186`)).resolves.toBe(12);
await expect(service.resolveBookIdByCoverImageId(5, '420-1778450221186')).resolves.toBe(420);
await expect(service.resolveBookIdByCoverImageId(5, 'not-a-cover-id')).resolves.toBeNull();
});
it('marks legacy numeric removal complete only when book ids are provided', async () => {
const db = makeDb();
const service = new KoboBookIdentityService(db as never);
await service.markLegacyNumericRemovalComplete(5, []);
expect(db.update).not.toHaveBeenCalled();
await service.markLegacyNumericRemovalComplete(5, [12, 12, 13]);
expect(db.update).toHaveBeenCalledTimes(1);
const updateChain = db.update.mock.results[0].value as { set: ReturnType<typeof vi.fn> };
expect(updateChain.set).toHaveBeenCalledWith({ needsLegacyNumericRemoval: false });
});
it('builds stable and versioned cover image ids', () => {
const service = new KoboBookIdentityService(makeDb() as never);
expect(service.buildVersionedCoverImageId(identity.coverImageId, null)).toBe(identity.coverImageId);
expect(service.buildVersionedCoverImageId(identity.coverImageId, new Date('2026-01-01T00:00:00.000Z'))).toBe(
`${identity.coverImageId}_1767225600000`,
);
});
});
@@ -0,0 +1,117 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
type Db = NodePgDatabase<typeof schema>;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const LEGACY_ENTITLEMENT_RE = /^\d+$/;
const LEGACY_COVER_RE = /^\d+(?:-\d+)?$/;
export type KoboBookIdentity = {
bookId: number;
entitlementId: string;
coverImageId: string;
needsLegacyNumericRemoval: boolean;
};
@Injectable()
export class KoboBookIdentityService {
constructor(@Inject(DB) private readonly db: Db) {}
async ensureForBook(userId: number, bookId: number, needsLegacyNumericRemoval: boolean): Promise<KoboBookIdentity> {
const identities = await this.ensureForBooks(userId, [bookId], needsLegacyNumericRemoval);
return identities.get(bookId)!;
}
async ensureForBooks(userId: number, bookIds: number[], needsLegacyNumericRemoval: boolean): Promise<Map<number, KoboBookIdentity>> {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) return new Map();
let identities = await this.findByBookIds(userId, uniqueBookIds);
const missingBookIds = uniqueBookIds.filter((bookId) => !identities.has(bookId));
if (missingBookIds.length > 0) {
await this.db
.insert(schema.koboBookEntitlements)
.values(missingBookIds.map((bookId) => ({ userId, bookId, needsLegacyNumericRemoval })))
.onConflictDoNothing();
identities = await this.findByBookIds(userId, uniqueBookIds);
}
return identities;
}
async findByBookIds(userId: number, bookIds: number[]): Promise<Map<number, KoboBookIdentity>> {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) return new Map();
const rows = await this.db
.select({
bookId: schema.koboBookEntitlements.bookId,
entitlementId: schema.koboBookEntitlements.entitlementId,
coverImageId: schema.koboBookEntitlements.coverImageId,
needsLegacyNumericRemoval: schema.koboBookEntitlements.needsLegacyNumericRemoval,
})
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, uniqueBookIds)));
return new Map(rows.map((row) => [row.bookId, row]));
}
async resolveBookIdByEntitlementId(userId: number, entitlementId: string): Promise<number | null> {
if (UUID_RE.test(entitlementId)) {
const [row] = await this.db
.select({ bookId: schema.koboBookEntitlements.bookId })
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.entitlementId, entitlementId)))
.limit(1);
return row?.bookId ?? null;
}
if (LEGACY_ENTITLEMENT_RE.test(entitlementId)) {
return Number(entitlementId);
}
return null;
}
async resolveBookIdByCoverImageId(userId: number, coverImageId: string): Promise<number | null> {
const baseCoverImageId = this.baseCoverImageId(coverImageId);
if (UUID_RE.test(baseCoverImageId)) {
const [row] = await this.db
.select({ bookId: schema.koboBookEntitlements.bookId })
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.coverImageId, baseCoverImageId)))
.limit(1);
return row?.bookId ?? null;
}
if (LEGACY_COVER_RE.test(coverImageId)) {
return Number(coverImageId.split('-')[0]);
}
return null;
}
async markLegacyNumericRemovalComplete(userId: number, bookIds: number[]): Promise<void> {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) return;
await this.db
.update(schema.koboBookEntitlements)
.set({ needsLegacyNumericRemoval: false })
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, uniqueBookIds)));
}
buildVersionedCoverImageId(coverImageId: string, version: Date | null): string {
return version ? `${coverImageId}_${version.getTime()}` : coverImageId;
}
private baseCoverImageId(coverImageId: string): string {
return coverImageId.split('_')[0] ?? coverImageId;
}
}
@@ -1,5 +1,4 @@
vi.mock('fs/promises', () => ({
mkdir: vi.fn(),
stat: vi.fn(),
}));
@@ -7,21 +6,14 @@ vi.mock('fs', () => ({
createReadStream: vi.fn(),
}));
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
import { NotFoundException } from '@nestjs/common';
import { execFile } from 'child_process';
import { createReadStream } from 'fs';
import { mkdir, stat } from 'fs/promises';
import { stat } from 'fs/promises';
import { KoboDownloadService } from './kobo-download.service';
const statMock = vi.mocked(stat);
const mkdirMock = vi.mocked(mkdir);
const createReadStreamMock = vi.mocked(createReadStream);
const execFileMock = vi.mocked(execFile);
function makeReply() {
return {
@@ -39,13 +31,21 @@ function makeDeps() {
bookFiles: { findFirst: vi.fn() },
},
},
config: { get: vi.fn().mockReturnValue('/app-data') },
kepubifyBinaryService: { getBinaryPath: vi.fn() },
kepubConversionService: { getKepubPath: vi.fn() },
settingsService: { getSettings: vi.fn() },
bookAccessService: { assertBookAccessible: vi.fn() },
};
}
function makeService(deps: ReturnType<typeof makeDeps>) {
return new KoboDownloadService(
deps.db as never,
deps.kepubConversionService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
}
describe('KoboDownloadService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -54,13 +54,7 @@ describe('KoboDownloadService', () => {
it('throws when target book does not exist', async () => {
const deps = makeDeps();
deps.db.query.books.findFirst.mockResolvedValue(null);
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
await expect(service.streamBook(7, 11, makeReply() as never)).rejects.toThrow(NotFoundException);
});
@@ -70,13 +64,7 @@ describe('KoboDownloadService', () => {
deps.db.query.books.findFirst.mockResolvedValue({ id: 11, primaryFileId: 22 });
deps.db.query.bookFiles.findFirst.mockResolvedValue(null);
deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
await expect(service.streamBook(7, 11, makeReply() as never)).rejects.toThrow('No file found for this book');
expect(deps.bookAccessService.assertBookAccessible).toHaveBeenCalledWith(7, 11);
@@ -89,17 +77,11 @@ describe('KoboDownloadService', () => {
id: 22,
format: 'pdf',
absolutePath: '/books/file.pdf',
hash: 'hash',
fileHash: 'hash',
sizeBytes: 100,
});
deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined);
await service.streamBook(7, 11, makeReply() as never);
@@ -114,7 +96,7 @@ describe('KoboDownloadService', () => {
id: 22,
format: 'epub',
absolutePath: '/books/file.epub',
hash: 'h1',
fileHash: 'h1',
sizeBytes: 5 * 1024 * 1024,
});
deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
@@ -122,14 +104,9 @@ describe('KoboDownloadService', () => {
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 10,
twoWayProgressSync: false,
});
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const streamKepubSpy = vi.spyOn(service as any, 'streamKepub').mockResolvedValue(undefined);
await service.streamBook(7, 11, makeReply() as never);
@@ -144,7 +121,7 @@ describe('KoboDownloadService', () => {
id: 22,
format: 'epub',
absolutePath: '/books/file.epub',
hash: null,
fileHash: null,
sizeBytes: 20 * 1024 * 1024,
});
deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
@@ -152,14 +129,9 @@ describe('KoboDownloadService', () => {
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 10,
twoWayProgressSync: false,
});
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined);
await service.streamBook(7, 11, makeReply() as never);
@@ -169,13 +141,7 @@ describe('KoboDownloadService', () => {
it('streamFile writes headers and stream payload and throws when source path is missing', async () => {
const deps = makeDeps();
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const reply = makeReply();
const stream = {} as never;
statMock.mockResolvedValueOnce({ size: 1234 } as never);
@@ -194,13 +160,7 @@ describe('KoboDownloadService', () => {
it('streamFile uses application/epub+zip for kepub.epub format', async () => {
const deps = makeDeps();
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const reply = makeReply();
statMock.mockResolvedValueOnce({ size: 4096 } as never);
createReadStreamMock.mockReturnValue({} as never);
@@ -213,13 +173,7 @@ describe('KoboDownloadService', () => {
it('streamFile falls back to application/octet-stream for unknown formats', async () => {
const deps = makeDeps();
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
const service = makeService(deps);
const reply = makeReply();
statMock.mockResolvedValueOnce({ size: 100 } as never);
createReadStreamMock.mockReturnValue({} as never);
@@ -229,52 +183,31 @@ describe('KoboDownloadService', () => {
expect(reply.type).toHaveBeenCalledWith('application/octet-stream');
});
it('streamKepub serves cached files when present', async () => {
it('streamKepub streams the shared conversion path', async () => {
const deps = makeDeps();
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
statMock.mockResolvedValueOnce({} as never);
const service = makeService(deps);
deps.kepubConversionService.getKepubPath.mockResolvedValue('/app-data/.kepub-cache/44/abc.kepub.epub');
const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined);
await (service as any).streamKepub('/books/source.epub', 'abc', 44, 55, false, makeReply());
expect(deps.kepubConversionService.getKepubPath).toHaveBeenCalledWith({
sourcePath: '/books/source.epub',
fileHash: 'abc',
bookId: 44,
hyphenate: false,
});
expect(streamFileSpy).toHaveBeenCalledWith('/app-data/.kepub-cache/44/abc.kepub.epub', 55, 'kepub.epub', expect.anything());
expect(execFileMock).not.toHaveBeenCalled();
});
it('streamKepub converts and caches on miss, then falls back when conversion fails', async () => {
it('streamKepub falls back when conversion fails', async () => {
const deps = makeDeps();
deps.kepubifyBinaryService.getBinaryPath.mockResolvedValue('/tools/kepubify');
const service = new KoboDownloadService(
deps.db as never,
deps.config as never,
deps.kepubifyBinaryService as never,
deps.settingsService as never,
deps.bookAccessService as never,
);
deps.kepubConversionService.getKepubPath.mockRejectedValue(new Error('convert failed'));
const service = makeService(deps);
const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined);
statMock.mockRejectedValueOnce(new Error('cache miss'));
execFileMock.mockImplementation((_path, _args, cb) => cb?.(null, '', ''));
await (service as any).streamKepub('/books/source.epub', 'hash', 44, 55, true, makeReply());
expect(mkdirMock).toHaveBeenCalledWith('/app-data/.kepub-cache/44', { recursive: true });
expect(execFileMock).toHaveBeenCalledWith(
'/tools/kepubify',
['--hyphenate', '--output', '/app-data/.kepub-cache/44/hash-hyph.kepub.epub', '/books/source.epub'],
expect.any(Function),
);
expect(streamFileSpy).toHaveBeenCalledWith('/app-data/.kepub-cache/44/hash-hyph.kepub.epub', 55, 'kepub.epub', expect.anything());
statMock.mockRejectedValueOnce(new Error('cache miss'));
execFileMock.mockImplementationOnce((_path, _args, cb) => cb?.(new Error('convert failed')));
await (service as any).streamKepub('/books/source.epub', 'hash', 44, 55, false, makeReply());
expect(streamFileSpy).toHaveBeenLastCalledWith('/books/source.epub', 55, 'epub', expect.anything());
});
});
@@ -1,25 +1,20 @@
import { execFile } from 'child_process';
import { createReadStream } from 'fs';
import { mkdir, stat } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { stat } from 'fs/promises';
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { and, eq } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { DB } from '../../../db/db.module';
import * as schema from '../../../db/schema';
import { KoboBookAccessService } from './kobo-book-access.service';
import { KepubifyBinaryService } from './kepubify-binary.service';
import { KepubConversionService } from './kepub-conversion.service';
import { KoboSettingsService } from './kobo-settings.service';
type Db = NodePgDatabase<typeof schema>;
const execFileAsync = promisify(execFile);
const MIME: Record<string, string> = {
epub: 'application/epub+zip',
'kepub.epub': 'application/epub+zip',
@@ -29,19 +24,13 @@ const MIME: Record<string, string> = {
@Injectable()
export class KoboDownloadService {
private readonly logger = new Logger(KoboDownloadService.name);
private readonly appDataPath: string;
private readonly kepubCachePath: string;
constructor(
@Inject(DB) private readonly db: Db,
private readonly config: ConfigService,
private readonly kepubifyBinaryService: KepubifyBinaryService,
private readonly kepubConversionService: KepubConversionService,
private readonly settingsService: KoboSettingsService,
private readonly bookAccessService: KoboBookAccessService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
this.kepubCachePath = join(this.appDataPath, '.kepub-cache');
}
) {}
async streamBook(userId: number, bookId: number, reply: FastifyReply) {
const book = await this.db.query.books.findFirst({ where: eq(schema.books.id, bookId) });
@@ -66,7 +55,7 @@ export class KoboDownloadService {
const limitBytes = settings.kepubConversionLimitMb * 1024 * 1024;
const withinLimit = !file.sizeBytes || file.sizeBytes <= limitBytes;
if (settings.convertToKepub && withinLimit) {
return this.streamKepub(file.absolutePath, file.hash ?? 'nohash', bookId, file.id, settings.forceEnableHyphenation, reply);
return this.streamKepub(file.absolutePath, file.fileHash ?? 'nohash', bookId, file.id, settings.forceEnableHyphenation, reply);
}
}
@@ -86,25 +75,15 @@ export class KoboDownloadService {
}
private async streamKepub(sourcePath: string, fileHash: string, bookId: number, fileId: number, hyphenate: boolean, reply: FastifyReply) {
const cacheDir = join(this.kepubCachePath, String(bookId));
const cacheKey = hyphenate ? `${fileHash}-hyph` : fileHash;
const cachedPath = join(cacheDir, `${cacheKey}.kepub.epub`);
const start = Date.now();
try {
await stat(cachedPath);
return this.streamFile(cachedPath, fileId, 'kepub.epub', reply);
} catch {
// Cache miss - convert
}
try {
const binaryPath = await this.kepubifyBinaryService.getBinaryPath();
await mkdir(cacheDir, { recursive: true });
const args = hyphenate ? ['--hyphenate', '--output', cachedPath, sourcePath] : ['--output', cachedPath, sourcePath];
await execFileAsync(binaryPath, args);
const cachedPath = await this.kepubConversionService.getKepubPath({ sourcePath, fileHash, bookId, hyphenate });
return this.streamFile(cachedPath, fileId, 'kepub.epub', reply);
} catch (err) {
this.logger.warn(`kepubify conversion failed for book ${bookId}, falling back to EPUB: ${(err as Error).message}`);
const error = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
`[kobo.download] [fail] bookId=${bookId} fileId=${fileId} durationMs=${Date.now() - start} errorClass=${error.constructor.name} error="${sanitizeLogValue(error.message)}" - kepub conversion failed, falling back to epub`,
);
return this.streamFile(sourcePath, fileId, 'epub', reply);
}
}
@@ -6,35 +6,59 @@ function makeInsertChain() {
return { values, onConflictDoUpdate };
}
function makeSelectChain(result: unknown[]) {
const chain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.innerJoin.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
chain.limit.mockResolvedValue(result);
return chain;
}
function makeDb() {
return {
query: {
books: { findFirst: vi.fn() },
koboLibrarySnapshots: { findFirst: vi.fn() },
koboReadingStates: { findFirst: vi.fn() },
},
select: vi.fn(() => makeSelectChain([])),
insert: vi.fn(),
execute: vi.fn().mockResolvedValue(undefined),
};
}
describe('KoboReadingStateService', () => {
const bookAccessService = { assertBookAccessible: vi.fn() };
const userBookStatusService = { autoUpdate: vi.fn() };
const bookIdentityService = { ensureForBook: vi.fn() };
function makeService(db: ReturnType<typeof makeDb>) {
return new KoboReadingStateService(db as never, bookAccessService as never, userBookStatusService as never);
return new KoboReadingStateService(db as never, bookAccessService as never, userBookStatusService as never, bookIdentityService as never);
}
beforeEach(() => {
vi.clearAllMocks();
bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
userBookStatusService.autoUpdate.mockResolvedValue(undefined);
bookIdentityService.ensureForBook.mockImplementation((_userId: number, bookId: number) => ({
bookId,
entitlementId: `entitlement-${bookId}`,
coverImageId: `cover-${bookId}`,
needsLegacyNumericRemoval: false,
}));
});
it('returns ignored update results when the target book is missing', async () => {
const db = makeDb();
db.query.books.findFirst.mockResolvedValue(null);
await expect(makeService(db).upsertState(7, 99, {}, 1, 99)).resolves.toEqual({
await expect(makeService(db).upsertState(7, 99, {}, 1, 99, false)).resolves.toEqual({
RequestResult: 'Success',
UpdateResults: [
{
@@ -46,6 +70,7 @@ describe('KoboReadingStateService', () => {
],
});
expect(userBookStatusService.autoUpdate).not.toHaveBeenCalled();
expect(bookAccessService.assertBookAccessible).not.toHaveBeenCalled();
});
it('merges reading state sub-objects by LastModified before storing raw state', async () => {
@@ -79,18 +104,20 @@ describe('KoboReadingStateService', () => {
},
1,
99,
false,
);
expect(stateInsert.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 3,
bookId: 12,
entitlementId: 'entitlement-12',
currentBookmark: { LastModified: '2026-01-02T00:00:00.000Z', ProgressPercent: 34 },
statistics: { LastModified: '2026-01-05T00:00:00.000Z', Value: 2 },
}),
);
expect(result).toEqual({
EntitlementId: '12',
EntitlementId: 'entitlement-12',
Created: '2026-01-01T00:00:00.000Z',
LastModified: '2026-01-03T00:00:00.000Z',
PriorityTimestamp: '2026-01-03T00:00:00.000Z',
@@ -101,6 +128,75 @@ describe('KoboReadingStateService', () => {
});
it('calls autoUpdate with merged percent and thresholds when bookmark has ProgressPercent', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
const progressInsert = makeInsertChain();
db.insert.mockReturnValueOnce(stateInsert).mockReturnValueOnce(progressInsert);
db.select
.mockReturnValueOnce(makeSelectChain([{ fileId: 55 }]))
.mockReturnValueOnce(makeSelectChain([{ percentage: 20, cfi: null, updatedAt: new Date('2025-12-31T00:00:00.000Z') }]));
db.query.books.findFirst.mockResolvedValue({ id: 5 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '5', currentBookmark: { ProgressPercent: 42.5 } });
await makeService(db).upsertState(
1,
5,
{
CurrentBookmark: {
LastModified: '2026-01-01T00:00:00Z',
ProgressPercent: 42.5,
ContentSourceProgressPercent: 12.25,
Location: { Source: 'OEBPS/html/ch5.xhtml', Type: 'KoboSpan', Value: 'kobo.25.1' },
},
},
1,
99,
true,
);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(1, 5, 42.5, 1, 99);
expect(progressInsert.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
bookFileId: 55,
percentage: 42.5,
cfi: null,
pageNumber: null,
koboLocationSource: 'OEBPS/html/ch5.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 12.25,
}),
);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('does not treat ContentSourceProgressPercent as whole-book progress', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
db.query.books.findFirst.mockResolvedValue({ id: 5 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '5', currentBookmark: { ContentSourceProgressPercent: 75 } });
await makeService(db).upsertState(
1,
5,
{ CurrentBookmark: { LastModified: '2026-01-01T00:00:00Z', ContentSourceProgressPercent: 75 } },
1,
99,
true,
);
expect(userBookStatusService.autoUpdate).not.toHaveBeenCalled();
expect(db.select).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
});
it('does not mirror Kobo percent to internal progress when two-way sync is disabled', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
@@ -109,9 +205,28 @@ describe('KoboReadingStateService', () => {
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '5', currentBookmark: { ProgressPercent: 42.5 } });
await makeService(db).upsertState(1, 5, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00Z', ProgressPercent: 42.5 } }, 1, 99);
await makeService(db).upsertState(1, 5, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00Z', ProgressPercent: 42.5 } }, 1, 99, false);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(1, 5, 42.5, 1, 99);
expect(db.select).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
});
it('does not replace newer internal CFI progress with stale Kobo percent', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
db.select
.mockReturnValueOnce(makeSelectChain([{ fileId: 60 }]))
.mockReturnValueOnce(makeSelectChain([{ percentage: 70, cfi: 'epubcfi(/6/2)', updatedAt: new Date('2026-01-02T00:00:00.000Z') }]));
db.query.books.findFirst.mockResolvedValue({ id: 6 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '6', currentBookmark: { ProgressPercent: 60 } });
await makeService(db).upsertState(1, 6, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00.000Z', ProgressPercent: 60 } }, 1, 99, true);
expect(db.insert).toHaveBeenCalledTimes(1);
});
it('does not call autoUpdate when bookmark has no percent', async () => {
@@ -121,13 +236,14 @@ describe('KoboReadingStateService', () => {
db.query.books.findFirst.mockResolvedValue({ id: 7 });
db.query.koboReadingStates.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ entitlementId: '7' });
await makeService(db).upsertState(2, 7, { Statistics: { LastModified: '2026-01-01T00:00:00Z' } }, 1, 99);
await makeService(db).upsertState(2, 7, { Statistics: { LastModified: '2026-01-01T00:00:00Z' } }, 1, 99, false);
expect(userBookStatusService.autoUpdate).not.toHaveBeenCalled();
});
it('getRawState returns null when absent and maps persisted fields when present', async () => {
const db = makeDb();
db.query.books.findFirst.mockResolvedValue({ id: 44 });
db.query.koboReadingStates.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({
entitlementId: '44',
createdAtKobo: '2026-01-01T00:00:00.000Z',
@@ -140,7 +256,7 @@ describe('KoboReadingStateService', () => {
await expect(makeService(db).getRawState(1, 44)).resolves.toBeNull();
await expect(makeService(db).getRawState(1, 44)).resolves.toEqual({
EntitlementId: '44',
EntitlementId: 'entitlement-44',
Created: '2026-01-01T00:00:00.000Z',
LastModified: '2026-01-02T00:00:00.000Z',
PriorityTimestamp: '2026-01-03T00:00:00.000Z',
@@ -149,4 +265,14 @@ describe('KoboReadingStateService', () => {
StatusInfo: { Status: 'ReadyToRead' },
});
});
it('getRawState returns null for missing legacy book ids without checking access', async () => {
const db = makeDb();
db.query.books.findFirst.mockResolvedValue(null);
await expect(makeService(db).getRawState(1, 1219)).resolves.toBeNull();
expect(bookAccessService.assertBookAccessible).not.toHaveBeenCalled();
expect(db.query.koboReadingStates.findFirst).not.toHaveBeenCalled();
});
});
@@ -6,9 +6,11 @@ import { DB } from '../../../db';
import * as schema from '../../../db/schema';
import { UserBookStatusService } from '../../user-book-status/user-book-status.service';
import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
type Db = NodePgDatabase<typeof schema>;
type JsonObj = Record<string, unknown>;
const PROGRESS_EPSILON = 0.0001;
function mergeSubObject(incoming: JsonObj | null | undefined, existing: JsonObj | null | undefined): JsonObj | null {
if (!incoming) return existing ?? null;
@@ -25,12 +27,17 @@ export class KoboReadingStateService {
@Inject(DB) private readonly db: Db,
private readonly bookAccessService: KoboBookAccessService,
private readonly userBookStatusService: UserBookStatusService,
private readonly bookIdentityService: KoboBookIdentityService,
) {}
async upsertState(userId: number, bookId: number, payload: Record<string, unknown>, readingThreshold: number, finishedThreshold: number) {
await this.bookAccessService.assertBookAccessible(userId, bookId);
const entitlementId = String(bookId);
async upsertState(
userId: number,
bookId: number,
payload: Record<string, unknown>,
readingThreshold: number,
finishedThreshold: number,
twoWayProgressSync: boolean,
) {
const now = new Date().toISOString();
const book = await this.db.query.books.findFirst({
@@ -38,6 +45,7 @@ export class KoboReadingStateService {
columns: { id: true },
});
if (!book) {
const entitlementId = String(bookId);
return {
RequestResult: 'Success',
UpdateResults: [
@@ -51,6 +59,11 @@ export class KoboReadingStateService {
};
}
await this.bookAccessService.assertBookAccessible(userId, bookId);
const identity = await this.bookIdentityService.ensureForBook(userId, bookId, await this.hasLibrarySnapshot(userId));
const entitlementId = identity.entitlementId;
const created = (payload.Created as string | undefined) ?? now;
const lastModified = (payload.LastModified as string | undefined) ?? now;
const priorityTimestamp = (payload.PriorityTimestamp as string | undefined) ?? lastModified;
@@ -95,6 +108,19 @@ export class KoboReadingStateService {
const percent = this.extractPercent(mergedBookmark);
if (percent !== null) {
if (twoWayProgressSync) {
await this.syncPercentToInternalProgress(
userId,
bookId,
percent,
this.extractProgressModifiedAt(mergedBookmark, lastModified),
this.extractKoboLocationSource(mergedBookmark),
this.extractKoboLocationType(mergedBookmark),
this.extractKoboLocationValue(mergedBookmark),
this.extractContentSourceProgressPercent(mergedBookmark),
);
await this.markSnapshotBookUnsynced(userId, bookId);
}
void this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold);
}
@@ -102,6 +128,12 @@ export class KoboReadingStateService {
}
async getRawState(userId: number, bookId: number): Promise<unknown> {
const book = await this.db.query.books.findFirst({
where: eq(schema.books.id, bookId),
columns: { id: true },
});
if (!book) return null;
await this.bookAccessService.assertBookAccessible(userId, bookId);
const row = await this.db.query.koboReadingStates.findFirst({
@@ -111,7 +143,7 @@ export class KoboReadingStateService {
if (!row) return null;
return {
EntitlementId: row.entitlementId,
EntitlementId: (await this.bookIdentityService.ensureForBook(userId, bookId, await this.hasLibrarySnapshot(userId))).entitlementId,
Created: row.createdAtKobo,
LastModified: row.lastModifiedKobo,
PriorityTimestamp: row.priorityTimestamp,
@@ -123,8 +155,147 @@ export class KoboReadingStateService {
private extractPercent(bookmark: JsonObj | null): number | null {
if (!bookmark) return null;
const pct = bookmark.ProgressPercent ?? bookmark.ContentSourceProgressPercent;
const pct = bookmark.ProgressPercent;
if (typeof pct === 'number') return Math.max(0, Math.min(100, pct));
return null;
}
private extractContentSourceProgressPercent(bookmark: JsonObj | null): number | null {
if (!bookmark) return null;
const pct = bookmark.ContentSourceProgressPercent;
if (typeof pct === 'number' && Number.isFinite(pct)) return Math.max(0, Math.min(100, pct));
return null;
}
private extractKoboLocationSource(bookmark: JsonObj | null): string | null {
return this.extractKoboLocationPart(bookmark, 'Source');
}
private extractKoboLocationType(bookmark: JsonObj | null): string | null {
return this.extractKoboLocationPart(bookmark, 'Type');
}
private extractKoboLocationValue(bookmark: JsonObj | null): string | null {
return this.extractKoboLocationPart(bookmark, 'Value');
}
private extractKoboLocationPart(bookmark: JsonObj | null, key: 'Source' | 'Type' | 'Value'): string | null {
const location = bookmark?.Location;
if (!location || typeof location !== 'object' || Array.isArray(location)) return null;
const value = (location as JsonObj)[key];
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private extractProgressModifiedAt(bookmark: JsonObj | null, fallback: string | undefined): Date {
const bookmarkModified = typeof bookmark?.LastModified === 'string' ? bookmark.LastModified : undefined;
return this.parseKoboTimestamp(bookmarkModified ?? fallback) ?? new Date();
}
private parseKoboTimestamp(value: string | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
private async syncPercentToInternalProgress(
userId: number,
bookId: number,
percentage: number,
sourceUpdatedAt: Date,
koboLocationSource: string | null,
koboLocationType: string | null,
koboLocationValue: string | null,
koboContentSourceProgressPercent: number | null,
): Promise<void> {
const [primaryFile] = await this.db
.select({ fileId: schema.bookFiles.id })
.from(schema.books)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.where(and(eq(schema.books.id, bookId), eq(schema.bookFiles.format, 'epub')))
.limit(1);
if (!primaryFile) return;
const [existing] = await this.db
.select({
percentage: schema.readingProgress.percentage,
cfi: schema.readingProgress.cfi,
koboLocationSource: schema.readingProgress.koboLocationSource,
koboLocationType: schema.readingProgress.koboLocationType,
koboLocationValue: schema.readingProgress.koboLocationValue,
koboContentSourceProgressPercent: schema.readingProgress.koboContentSourceProgressPercent,
updatedAt: schema.readingProgress.updatedAt,
})
.from(schema.readingProgress)
.where(and(eq(schema.readingProgress.userId, userId), eq(schema.readingProgress.bookFileId, primaryFile.fileId)))
.limit(1);
if (existing?.updatedAt && existing.updatedAt.getTime() >= sourceUpdatedAt.getTime()) return;
const samePercent = existing ? Math.abs(existing.percentage - percentage) < PROGRESS_EPSILON : false;
if (
samePercent &&
existing?.cfi &&
existing.koboLocationSource === koboLocationSource &&
existing.koboLocationType === koboLocationType &&
existing.koboLocationValue === koboLocationValue &&
existing.koboContentSourceProgressPercent === koboContentSourceProgressPercent
) {
return;
}
const nextCfi = samePercent ? (existing?.cfi ?? null) : null;
await this.db
.insert(schema.readingProgress)
.values({
userId,
bookFileId: primaryFile.fileId,
percentage,
cfi: nextCfi,
pageNumber: null,
positionSeconds: null,
koboLocationSource,
koboLocationType,
koboLocationValue,
koboContentSourceProgressPercent,
updatedAt: sourceUpdatedAt,
})
.onConflictDoUpdate({
target: [schema.readingProgress.bookFileId, schema.readingProgress.userId],
set: {
percentage,
cfi: nextCfi,
pageNumber: null,
positionSeconds: null,
koboLocationSource,
koboLocationType,
koboLocationValue,
koboContentSourceProgressPercent,
updatedAt: sourceUpdatedAt,
},
});
}
private async markSnapshotBookUnsynced(userId: number, bookId: number): Promise<void> {
await this.db.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${schema.koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
private async hasLibrarySnapshot(userId: number): Promise<boolean> {
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
where: eq(schema.koboLibrarySnapshots.userId, userId),
columns: { id: true },
});
return Boolean(snapshot);
}
}
@@ -7,9 +7,10 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
userId: 9,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
...overrides,
};
}
@@ -26,6 +27,7 @@ function makeDb() {
const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning });
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const update = vi.fn().mockReturnValue({ set: updateSet });
const execute = vi.fn().mockResolvedValue(undefined);
return {
query: {
@@ -41,6 +43,7 @@ function makeDb() {
updateSet,
updateWhere,
updateReturning,
execute,
};
}
@@ -60,6 +63,7 @@ describe('KoboSettingsService', () => {
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
expect(db.insert).not.toHaveBeenCalled();
});
@@ -73,13 +77,32 @@ describe('KoboSettingsService', () => {
await expect(service.getSettings(7)).resolves.toEqual({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 150,
twoWayProgressSync: false,
});
expect(db.insertValues).toHaveBeenCalledWith({ userId: 7 });
});
it('self-heals stale settings where two-way sync is enabled without KEPUB conversion', async () => {
const db = makeDb();
db.query.koboSyncSettings.findFirst.mockResolvedValue(makeRow({ convertToKepub: false, twoWayProgressSync: true }));
db.updateReturning.mockResolvedValue([makeRow({ convertToKepub: true, twoWayProgressSync: true })]);
const service = new KoboSettingsService(db as never);
await expect(service.getSettings(9)).resolves.toEqual({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
});
expect(db.updateSet).toHaveBeenCalledWith(expect.objectContaining({ convertToKepub: true }));
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('throws when settings row cannot be created', async () => {
const db = makeDb();
db.query.koboSyncSettings.findFirst.mockResolvedValue(null);
@@ -98,22 +121,56 @@ describe('KoboSettingsService', () => {
convertToKepub: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
db.updateReturning.mockResolvedValue([
makeRow({
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
twoWayProgressSync: true,
}),
]);
await expect(service.updateSettings(9, { convertToKepub: true })).resolves.toEqual({
await expect(service.updateSettings(9, { convertToKepub: false, twoWayProgressSync: true })).resolves.toEqual({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
twoWayProgressSync: true,
});
expect(db.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
convertToKepub: true,
twoWayProgressSync: true,
}),
);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('does not mark Kobo books for re-delivery when only progress thresholds change', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
db.updateReturning.mockResolvedValue([makeRow({ readingThreshold: 5, finishedThreshold: 95 })]);
await expect(service.updateSettings(9, { readingThreshold: 5, finishedThreshold: 95 })).resolves.toEqual({
readingThreshold: 5,
finishedThreshold: 95,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
expect(db.execute).not.toHaveBeenCalled();
});
it('throws when update does not return a row', async () => {
@@ -125,6 +182,7 @@ describe('KoboSettingsService', () => {
convertToKepub: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
db.updateReturning.mockResolvedValue([]);
@@ -140,6 +198,7 @@ describe('KoboSettingsService', () => {
convertToKepub: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
await expect(service.updateSettings(9, { readingThreshold: 99, finishedThreshold: 99 })).rejects.toThrow(
@@ -13,6 +13,7 @@ export interface KoboSettings {
convertToKepub: boolean;
forceEnableHyphenation: boolean;
kepubConversionLimitMb: number;
twoWayProgressSync: boolean;
}
@Injectable()
@@ -30,12 +31,23 @@ export class KoboSettingsService {
}
if (!row) throw new InternalServerErrorException('Failed to create kobo settings row');
if (row.twoWayProgressSync && !row.convertToKepub) {
const [updated] = await this.db
.update(schema.koboSyncSettings)
.set({ convertToKepub: true, updatedAt: sql`now()` })
.where(eq(schema.koboSyncSettings.userId, userId))
.returning();
row = updated ?? { ...row, convertToKepub: true };
await this.markSnapshotDeliveryChanged(userId);
}
return {
readingThreshold: row.readingThreshold,
finishedThreshold: row.finishedThreshold,
convertToKepub: row.convertToKepub,
forceEnableHyphenation: row.forceEnableHyphenation,
kepubConversionLimitMb: row.kepubConversionLimitMb,
twoWayProgressSync: row.twoWayProgressSync,
};
}
@@ -44,6 +56,8 @@ export class KoboSettingsService {
const newReading = patch.readingThreshold ?? current.readingThreshold;
const newFinished = patch.finishedThreshold ?? current.finishedThreshold;
const newTwoWayProgressSync = patch.twoWayProgressSync ?? current.twoWayProgressSync;
const newConvertToKepub = newTwoWayProgressSync ? true : (patch.convertToKepub ?? current.convertToKepub);
if (newReading >= newFinished) {
throw new BadRequestException('Reading threshold must be less than finished threshold');
@@ -54,9 +68,10 @@ export class KoboSettingsService {
.set({
readingThreshold: newReading,
finishedThreshold: newFinished,
convertToKepub: patch.convertToKepub ?? current.convertToKepub,
convertToKepub: newConvertToKepub,
forceEnableHyphenation: patch.forceEnableHyphenation ?? current.forceEnableHyphenation,
kepubConversionLimitMb: patch.kepubConversionLimitMb ?? current.kepubConversionLimitMb,
twoWayProgressSync: newTwoWayProgressSync,
updatedAt: sql`now()`,
})
.where(eq(schema.koboSyncSettings.userId, userId))
@@ -66,12 +81,42 @@ export class KoboSettingsService {
throw new InternalServerErrorException('Failed to update kobo settings row');
}
if (this.deliverySettingsChanged(current, updated)) {
await this.markSnapshotDeliveryChanged(userId);
}
return {
readingThreshold: updated.readingThreshold,
finishedThreshold: updated.finishedThreshold,
convertToKepub: updated.convertToKepub,
forceEnableHyphenation: updated.forceEnableHyphenation,
kepubConversionLimitMb: updated.kepubConversionLimitMb,
twoWayProgressSync: updated.twoWayProgressSync,
};
}
private deliverySettingsChanged(
current: Pick<KoboSettings, 'convertToKepub' | 'forceEnableHyphenation' | 'kepubConversionLimitMb'>,
updated: Pick<KoboSettings, 'convertToKepub' | 'forceEnableHyphenation' | 'kepubConversionLimitMb'>,
): boolean {
return (
current.convertToKepub !== updated.convertToKepub ||
current.kepubConversionLimitMb !== updated.kepubConversionLimitMb ||
((current.convertToKepub || updated.convertToKepub) && current.forceEnableHyphenation !== updated.forceEnableHyphenation)
);
}
private async markSnapshotDeliveryChanged(userId: number): Promise<void> {
await this.db.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET synced = false,
is_new = true,
delivery_hash = NULL
FROM ${schema.koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
}
@@ -64,6 +64,7 @@ function makeDb(state?: Partial<QueueState>) {
query: {
koboLibrarySnapshots: { findFirst: vi.fn() },
koboSnapshotBooks: { findFirst: vi.fn() },
koboSyncSettings: { findFirst: vi.fn() },
collections: { findMany: vi.fn() },
},
select: vi.fn(() => makeChain(queue.select.shift() ?? [])),
@@ -82,6 +83,9 @@ function makeDb(state?: Partial<QueueState>) {
function makeBook(id: number, format = 'epub') {
return {
bookId: id,
koboEntitlementId: `entitlement-${id}`,
koboCoverImageId: `cover-${id}_1767225600000`,
needsLegacyNumericRemoval: false,
title: `Book ${id}`,
authors: ['Author One'],
description: 'Description',
@@ -94,6 +98,7 @@ function makeBook(id: number, format = 'epub') {
fileSizeBytes: 1234,
fileHash: `hash-${id}`,
metadataHash: `meta-${id}`,
deliveryFormat: format === 'pdf' ? 'PDF' : 'EPUB3',
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
collectionNames: ['Sci-Fi'],
addedAt: new Date('2026-01-01T00:00:00.000Z'),
@@ -108,17 +113,58 @@ describe('KoboSyncService', () => {
const readingStateService = {
getRawState: vi.fn(),
};
const contentFilterRepository = {
findByUserId: vi.fn(),
};
const bookIdentityService = {
ensureForBooks: vi.fn(),
findByBookIds: vi.fn(),
markLegacyNumericRemovalComplete: vi.fn(),
buildVersionedCoverImageId: vi.fn(),
};
function makeIdentity(bookId: number, needsLegacyNumericRemoval = false) {
return {
bookId,
entitlementId: `entitlement-${bookId}`,
coverImageId: `cover-${bookId}`,
needsLegacyNumericRemoval,
};
}
function makeIdentityMap(bookIds: number[], needsLegacyNumericRemoval = false) {
return new Map([...new Set(bookIds)].map((bookId) => [bookId, makeIdentity(bookId, needsLegacyNumericRemoval)]));
}
function makeService(db: unknown) {
return new KoboSyncService(
db as never,
bookAccessService as never,
readingStateService as never,
contentFilterRepository as never,
bookIdentityService as never,
);
}
beforeEach(() => {
vi.clearAllMocks();
bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null);
readingStateService.getRawState.mockResolvedValue(null);
contentFilterRepository.findByUserId.mockResolvedValue([]);
bookIdentityService.ensureForBooks.mockImplementation((_userId: number, bookIds: number[], needsLegacyNumericRemoval: boolean) =>
makeIdentityMap(bookIds, needsLegacyNumericRemoval),
);
bookIdentityService.findByBookIds.mockImplementation((_userId: number, bookIds: number[]) => makeIdentityMap(bookIds));
bookIdentityService.markLegacyNumericRemovalComplete.mockResolvedValue(undefined);
bookIdentityService.buildVersionedCoverImageId.mockImplementation((coverImageId: string, version: Date | null) =>
version ? `${coverImageId}_${version.getTime()}` : coverImageId,
);
});
it('getDelta creates new snapshot when missing and reconciles existing snapshot otherwise', async () => {
const db = makeDb();
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const eligible = [{ bookId: 1, fileHash: 'h1', metadataHash: 'm1' }];
const service = makeService(db);
const eligible = [{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1' }];
vi.spyOn(service as any, 'fetchEligibleSnapshotRows').mockResolvedValue(eligible);
const createSpy = vi.spyOn(service as any, 'createSnapshot').mockResolvedValue(undefined);
const reconcileSpy = vi.spyOn(service as any, 'reconcileSnapshot').mockResolvedValue(undefined);
@@ -143,24 +189,27 @@ describe('KoboSyncService', () => {
});
it('getBookMetadata returns empty array when book is not eligible', async () => {
const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never);
const service = makeService(makeDb());
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map());
await expect(service.getBookMetadata(3, 99, 'tok', 'https://base')).resolves.toEqual([]);
});
it('getBookMetadata returns mapped metadata payload for eligible book', async () => {
const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never);
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[12, makeBook(12, 'pdf')]]));
const db = makeDb();
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 1 });
const service = makeService(db);
const fetchSpy = vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[12, makeBook(12, 'pdf')]]));
const [metadata] = (await service.getBookMetadata(3, 12, 'tok', 'https://base')) as Array<Record<string, unknown>>;
expect(fetchSpy).toHaveBeenCalledWith(3, [12], true);
expect(metadata.Title).toBe('Book 12');
expect(metadata.DownloadUrls).toEqual([
{
Format: 'PDF',
Size: 1234,
Url: 'https://base/api/v1/kobo/tok/v1/books/12/download',
Url: 'https://base/api/v1/kobo/tok/v1/books/entitlement-12/download',
Platform: 'Generic',
DrmType: 'None',
},
@@ -169,7 +218,7 @@ describe('KoboSyncService', () => {
it('removeBookFromSync handles missing snapshot/row and delete-vs-mark paths', async () => {
const db = makeDb();
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
db.query.koboLibrarySnapshots.findFirst
.mockResolvedValueOnce(null)
@@ -193,7 +242,7 @@ describe('KoboSyncService', () => {
it('invalidateSnapshot marks snapshot rows unsynced only when snapshot exists', async () => {
const db = makeDb();
db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 21, userId: 9 });
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
await service.invalidateSnapshot(9);
await service.invalidateSnapshot(9);
@@ -203,11 +252,11 @@ describe('KoboSyncService', () => {
it('createSnapshot inserts snapshot row and snapshot-books seed rows', async () => {
const db = makeDb({ insert: [[{ id: 55 }], []] });
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
await (service as any).createSnapshot(7, [
{ bookId: 1, fileHash: 'h1', metadataHash: 'm1' },
{ bookId: 2, fileHash: null, metadataHash: 'm2' },
{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1' },
{ bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2' },
]);
expect(db.insert).toHaveBeenCalledTimes(2);
@@ -216,7 +265,7 @@ describe('KoboSyncService', () => {
it('getPageFromSnapshot returns empty result when no snapshot exists', async () => {
const db = makeDb();
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue(null);
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
await expect((service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set())).resolves.toEqual({
entitlements: [],
@@ -228,7 +277,7 @@ describe('KoboSyncService', () => {
it('getPageFromSnapshot returns tags on final page when no pending rows', async () => {
const db = makeDb({ select: [[]] });
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 1, userId: 7 });
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
vi.spyOn(service as any, 'buildTagItems').mockResolvedValue([{ ChangedTag: {} }]);
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1]));
@@ -250,7 +299,7 @@ describe('KoboSyncService', () => {
],
});
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(
new Map([
[2, makeBook(2)],
@@ -270,6 +319,48 @@ describe('KoboSyncService', () => {
expect(db.update).toHaveBeenCalled();
});
it('getPageFromSnapshot includes changed reading state for changed books with stored Kobo state', async () => {
const db = makeDb({
select: [[{ bookId: 3, pendingDelete: false, isNew: false }]],
});
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
const service = makeService(db);
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, makeBook(3)]]));
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]));
expect(result.entitlements).toHaveLength(2);
expect(result.entitlements[0]).toHaveProperty('ChangedProductMetadata');
expect(result.entitlements[1]).toEqual({
ChangedReadingState: {
ReadingState: { EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } },
},
});
});
it('getPageFromSnapshot sends a replacement entitlement for changed books still using legacy numeric ids', async () => {
const db = makeDb({
select: [[{ bookId: 3, pendingDelete: false, isNew: false }]],
});
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 });
const service = makeService(db);
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, { ...makeBook(3), needsLegacyNumericRemoval: true }]]));
bookIdentityService.findByBookIds.mockResolvedValue(new Map([[3, makeIdentity(3, true)]]));
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]));
expect(result.entitlements).toHaveLength(2);
expect(result.entitlements[0]).toHaveProperty('ChangedEntitlement');
expect(result.entitlements[1]).toEqual({
NewEntitlement: expect.objectContaining({
ReadingState: { EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } },
}),
});
expect(bookIdentityService.markLegacyNumericRemovalComplete).toHaveBeenCalledWith(7, [3]);
});
it('buildTagItems includes only currently-eligible books per collection', async () => {
const db = makeDb({
select: [
@@ -284,7 +375,7 @@ describe('KoboSyncService', () => {
{ id: 1, name: 'Favorites' },
{ id: 2, name: 'Comics' },
]);
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
const tags = await (service as any).buildTagItems(3, new Set([10, 22]));
@@ -294,7 +385,7 @@ describe('KoboSyncService', () => {
ChangedTag: expect.objectContaining({
Tag: expect.objectContaining({
Id: 'col-1',
Items: [{ RevisionId: '10', Type: 'ProductRevisionTagItem' }],
Items: [{ RevisionId: 'entitlement-10', Type: 'ProductRevisionTagItem' }],
}),
}),
}),
@@ -350,29 +441,29 @@ describe('KoboSyncService', () => {
return [];
}
it('issues only CREATE TEMP and 6 maintenance queries when eligibleBooks is empty', async () => {
it('issues only CREATE TEMP and 8 maintenance queries when eligibleBooks is empty', async () => {
const { fn: txExecute } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
await (service as any).reconcileSnapshot(42, []);
// CREATE TEMP + 6 maintenance queries (no batch insert when list is empty)
expect(txExecute).toHaveBeenCalledTimes(7);
// CREATE TEMP + 8 maintenance queries (no batch insert when list is empty)
expect(txExecute).toHaveBeenCalledTimes(9);
});
it('issues one batch INSERT when eligibleBooks fits in a single batch', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
await (service as any).reconcileSnapshot(10, [
{ bookId: 1, fileHash: 'h1', metadataHash: 'm1' },
{ bookId: 2, fileHash: null, metadataHash: 'm2' },
{ bookId: 1, fileHash: 'h1', deliveryHash: 'd1', metadataHash: 'm1' },
{ bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2' },
]);
// CREATE TEMP + 1 batch INSERT + 6 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(8);
// CREATE TEMP + 1 batch INSERT + 8 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(10);
const batchInsert = captured[1];
const sqlStrings = extractSqlStrings(batchInsert);
@@ -380,59 +471,62 @@ describe('KoboSyncService', () => {
expect(sqlStrings.some((s) => s.includes('unnest'))).toBe(false);
});
it('passes correct bookId, fileHash, and metadataHash as individual params in VALUES rows', async () => {
it('passes correct bookId, fileHash, deliveryHash, and metadataHash as individual params in VALUES rows', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', metadataHash: 'xyz' }]);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz' }]);
const batchInsert = captured[1];
const params = extractSqlParams(batchInsert);
expect(params).toContain(7);
expect(params).toContain('abc');
expect(params).toContain('delivery');
expect(params).toContain('xyz');
});
it('passes null fileHash correctly in VALUES rows', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
await (service as any).reconcileSnapshot(5, [{ bookId: 3, fileHash: null, metadataHash: 'mhash' }]);
await (service as any).reconcileSnapshot(5, [{ bookId: 3, fileHash: null, deliveryHash: 'delivery', metadataHash: 'mhash' }]);
const batchInsert = captured[1];
const params = extractSqlParams(batchInsert);
expect(params).toContain(null);
expect(params).toContain('delivery');
expect(params).toContain('mhash');
});
it('issues two batch INSERTs when eligibleBooks exceeds the 5000-item batch size', async () => {
const { fn: txExecute } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
const eligible = Array.from({ length: 5001 }, (_, i) => ({
bookId: i + 1,
fileHash: `h${i}`,
deliveryHash: `d${i}`,
metadataHash: `m${i}`,
}));
await (service as any).reconcileSnapshot(99, eligible);
// CREATE TEMP + 2 batch INSERTs + 6 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(9);
// CREATE TEMP + 2 batch INSERTs + 8 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(11);
});
it('includes snapshotId as a param in all maintenance queries', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
const snapshotId = 77;
await (service as any).reconcileSnapshot(snapshotId, [{ bookId: 1, fileHash: 'f', metadataHash: 'm' }]);
await (service as any).reconcileSnapshot(snapshotId, [{ bookId: 1, fileHash: 'f', deliveryHash: 'd', metadataHash: 'm' }]);
// Statements at index 2-7 are the 6 maintenance queries
const maintenanceStmts = captured.slice(2, 8);
// Statements at index 2-9 are the 8 maintenance queries
const maintenanceStmts = captured.slice(2, 10);
for (const stmt of maintenanceStmts) {
const params = extractSqlParams(stmt);
expect(params).toContain(snapshotId);
@@ -442,11 +536,12 @@ describe('KoboSyncService', () => {
it('each batch only contains its own chunk of books', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
const eligible = Array.from({ length: 5002 }, (_, i) => ({
bookId: i + 1,
fileHash: `h${i}`,
deliveryHash: `d${i}`,
metadataHash: `m${i}`,
}));
await (service as any).reconcileSnapshot(1, eligible);
@@ -467,9 +562,9 @@ describe('KoboSyncService', () => {
it('resets device-removed rows that remain eligible for re-delivery', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = new KoboSyncService(db as never, {} as never, {} as never);
const service = makeService(db);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', metadataHash: 'xyz' }]);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz' }]);
const resetStmt = captured.find((stmt) => {
const sql = extractSqlStrings(stmt).join(' ');
@@ -481,10 +576,34 @@ describe('KoboSyncService', () => {
expect(sql).toContain('synced = false');
expect(sql).toContain('is_new = true');
});
it('marks delivery changes as new entitlements and metadata-only changes as metadata updates', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = makeService(db);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz' }]);
const deliveryStmt = captured.find((stmt) => {
const sql = extractSqlStrings(stmt).join(' ');
return sql.includes('sb.delivery_hash IS DISTINCT FROM e.delivery_hash');
});
const metadataStmt = captured.find((stmt) => {
const sql = extractSqlStrings(stmt).join(' ');
return sql.includes('sb.metadata_hash IS DISTINCT FROM e.metadata_hash');
});
expect(deliveryStmt).toBeDefined();
expect(extractSqlStrings(deliveryStmt).join(' ')).toContain('is_new = true');
expect(metadataStmt).toBeDefined();
const metadataSql = extractSqlStrings(metadataStmt).join(' ');
expect(metadataSql).toContain('is_new = false');
expect(metadataSql).toContain('sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash');
});
});
it('buildMetadataHash is deterministic and changes when metadata inputs change', () => {
const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never);
const service = makeService(makeDb());
const hashA = (service as any).buildMetadataHash({
title: 'Dune',
@@ -492,6 +611,8 @@ describe('KoboSyncService', () => {
seriesName: 'Dune',
seriesIndex: 1,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
entitlementId: 'entitlement-1',
coverImageId: 'cover-1',
});
const hashB = (service as any).buildMetadataHash({
title: 'Dune',
@@ -499,6 +620,8 @@ describe('KoboSyncService', () => {
seriesName: 'Dune',
seriesIndex: 1,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
entitlementId: 'entitlement-1',
coverImageId: 'cover-1',
});
const hashC = (service as any).buildMetadataHash({
title: 'Dune Messiah',
@@ -506,13 +629,73 @@ describe('KoboSyncService', () => {
seriesName: 'Dune',
seriesIndex: 2,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
entitlementId: 'entitlement-1',
coverImageId: 'cover-1',
});
const hashE = (service as any).buildMetadataHash({
title: 'Dune',
authors: ['Frank Herbert'],
seriesName: 'Dune',
seriesIndex: 1,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
entitlementId: 'entitlement-2',
coverImageId: 'cover-1',
});
const hashF = (service as any).buildMetadataHash({
title: 'Dune',
authors: ['Frank Herbert'],
seriesName: 'Dune',
seriesIndex: 1,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
entitlementId: 'entitlement-1',
coverImageId: 'cover-2',
});
expect(hashA).toBe(hashB);
expect(hashA).not.toBe(hashC);
expect(hashA).not.toBe(hashE);
expect(hashA).not.toBe(hashF);
expect(hashA).toHaveLength(16);
});
it('maps settings and file metadata into the actual Kobo delivery format', () => {
const service = makeService(makeDb());
const kepub = (service as any).getDeliveryInfo('epub', 1024, {
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 1,
});
const hyphenatedKepub = (service as any).getDeliveryInfo('epub', 1024, {
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 1,
});
const oversizedEpub = (service as any).getDeliveryInfo('epub', 2 * 1024 * 1024, {
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 1,
});
const disabledEpub = (service as any).getDeliveryInfo('epub', 1024, {
convertToKepub: false,
forceEnableHyphenation: true,
kepubConversionLimitMb: 1,
});
expect(kepub.format).toBe('KEPUB');
expect(hyphenatedKepub.format).toBe('KEPUB');
expect(hyphenatedKepub.hash).not.toBe(kepub.hash);
expect(oversizedEpub.format).toBe('EPUB3');
expect(disabledEpub.format).toBe('EPUB3');
expect(
(service as any).getDeliveryInfo('pdf', 1024, {
convertToKepub: false,
forceEnableHyphenation: true,
kepubConversionLimitMb: 1,
}).format,
).toBe('PDF');
});
it('fetchEligibleSnapshotRows and fetchEligibleBooksByIds map DB rows into sync payload objects', async () => {
const db = makeDb({
select: [
@@ -523,6 +706,8 @@ describe('KoboSyncService', () => {
seriesName: 'Saga',
seriesIndex: 2,
metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'),
fileFormat: 'epub',
fileSizeBytes: 1234,
fileHash: 'file-hash',
authorNamesCsv: 'Author A,Author B',
},
@@ -549,19 +734,20 @@ describe('KoboSyncService', () => {
[{ bookId: 5, name: 'Collection A' }],
],
});
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
const service = makeService(db);
vi.spyOn(service as any, 'buildEligibleBooksWhereClause').mockResolvedValue({ where: true });
const snapshotRows = await (service as any).fetchEligibleSnapshotRows(8);
const snapshotRows = await (service as any).fetchEligibleSnapshotRows(8, true);
expect(snapshotRows).toEqual([
{
bookId: 5,
fileHash: 'file-hash',
deliveryHash: expect.any(String),
metadataHash: expect.any(String),
},
]);
const books = await (service as any).fetchEligibleBooksByIds(8, [5, 5]);
const books = await (service as any).fetchEligibleBooksByIds(8, [5, 5], true);
expect(books.get(5)).toEqual(
expect.objectContaining({
title: 'Dune',
@@ -8,6 +8,7 @@ import * as schema from '../../../db/schema';
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
import { ContentFilterRepository } from '../../user/content-filter.repository';
import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
import { KoboReadingStateService } from './kobo-reading-state.service';
type Db = NodePgDatabase<typeof schema>;
@@ -19,11 +20,28 @@ const TOKEN_PREFIX = 'PX.';
type EligibleSnapshotRow = {
bookId: number;
fileHash: string | null;
deliveryHash: string;
metadataHash: string;
};
type KoboDeliverySettings = {
convertToKepub: boolean;
forceEnableHyphenation: boolean;
kepubConversionLimitMb: number;
};
type KoboDeliveryFormat = 'EPUB3' | 'KEPUB' | 'PDF';
type KoboDeliveryInfo = {
format: KoboDeliveryFormat;
hash: string;
};
export interface KoboBookEntry {
bookId: number;
koboEntitlementId: string;
koboCoverImageId: string;
needsLegacyNumericRemoval: boolean;
title: string;
authors: string[];
description: string | null;
@@ -36,6 +54,7 @@ export interface KoboBookEntry {
fileSizeBytes: number | null;
fileHash: string | null;
metadataHash: string;
deliveryFormat: KoboDeliveryFormat;
metadataUpdatedAt: Date | null;
collectionNames: string[];
addedAt: Date;
@@ -53,6 +72,7 @@ export class KoboSyncService {
private readonly bookAccessService: KoboBookAccessService,
private readonly readingStateService: KoboReadingStateService,
private readonly contentFilterRepository: ContentFilterRepository,
private readonly bookIdentityService: KoboBookIdentityService,
) {}
async getDelta(userId: number, deviceToken: string, baseUrl: string): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
@@ -60,7 +80,8 @@ export class KoboSyncService {
where: eq(schema.koboLibrarySnapshots.userId, userId),
});
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId);
const hadSnapshot = Boolean(snapshot);
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId, hadSnapshot);
if (!snapshot) {
await this.createSnapshot(userId, eligibleSnapshotRows);
@@ -72,7 +93,11 @@ export class KoboSyncService {
}
async getBookMetadata(userId: number, bookId: number, deviceToken: string, baseUrl: string): Promise<unknown[]> {
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId]);
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
where: eq(schema.koboLibrarySnapshots.userId, userId),
columns: { id: true },
});
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId], Boolean(snapshot));
const book = booksById.get(bookId) ?? null;
if (!book) return [];
return [this.buildBookMetadata(book, deviceToken, baseUrl)];
@@ -123,6 +148,7 @@ export class KoboSyncService {
isNew: true,
removedByDevice: false,
fileHash: b.fileHash,
deliveryHash: b.deliveryHash,
metadataHash: b.metadataHash,
})),
)
@@ -136,6 +162,7 @@ export class KoboSyncService {
CREATE TEMP TABLE kobo_eligible_books_tmp (
book_id integer PRIMARY KEY,
file_hash varchar(64),
delivery_hash varchar(64) NOT NULL,
metadata_hash varchar(64) NOT NULL
) ON COMMIT DROP
`);
@@ -143,16 +170,17 @@ export class KoboSyncService {
for (let index = 0; index < eligibleBooks.length; index += SNAPSHOT_RECONCILE_BATCH_SIZE) {
const chunk = eligibleBooks.slice(index, index + SNAPSHOT_RECONCILE_BATCH_SIZE);
const valueRows = sql.join(
chunk.map((b) => sql`(${b.bookId}, ${b.fileHash}, ${b.metadataHash})`),
chunk.map((b) => sql`(${b.bookId}, ${b.fileHash}, ${b.deliveryHash}, ${b.metadataHash})`),
sql`, `,
);
await tx.execute(sql`
INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, metadata_hash)
INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, delivery_hash, metadata_hash)
VALUES ${valueRows}
ON CONFLICT (book_id)
DO UPDATE
SET file_hash = EXCLUDED.file_hash,
delivery_hash = EXCLUDED.delivery_hash,
metadata_hash = EXCLUDED.metadata_hash
`);
}
@@ -178,8 +206,8 @@ export class KoboSyncService {
await tx.execute(sql`
INSERT INTO ${schema.koboSnapshotBooks}
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, file_hash, metadata_hash)
SELECT ${snapshotId}, e.book_id, false, false, true, false, e.file_hash, e.metadata_hash
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, file_hash, delivery_hash, metadata_hash)
SELECT ${snapshotId}, e.book_id, false, false, true, false, e.file_hash, e.delivery_hash, e.metadata_hash
FROM kobo_eligible_books_tmp e
LEFT JOIN ${schema.koboSnapshotBooks} sb
ON sb.snapshot_id = ${snapshotId}
@@ -193,6 +221,7 @@ export class KoboSyncService {
synced = false,
is_new = true,
file_hash = e.file_hash,
delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
@@ -207,6 +236,7 @@ export class KoboSyncService {
synced = false,
is_new = true,
file_hash = e.file_hash,
delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
@@ -216,18 +246,50 @@ export class KoboSyncService {
AND sb.pending_delete = false
`);
await tx.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET delivery_hash = e.delivery_hash
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id
AND sb.pending_delete = false
AND sb.removed_by_device = false
AND sb.delivery_hash IS NULL
AND sb.file_hash IS NOT DISTINCT FROM e.file_hash
`);
await tx.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET synced = false,
is_new = false,
is_new = true,
file_hash = e.file_hash,
delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id
AND sb.pending_delete = false
AND sb.removed_by_device = false
AND sb.synced = true
AND (sb.file_hash IS DISTINCT FROM e.file_hash OR sb.metadata_hash IS DISTINCT FROM e.metadata_hash)
AND (sb.file_hash IS DISTINCT FROM e.file_hash OR sb.delivery_hash IS DISTINCT FROM e.delivery_hash)
`);
await tx.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET synced = false,
is_new = false,
file_hash = e.file_hash,
delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id
AND sb.pending_delete = false
AND sb.removed_by_device = false
AND sb.synced = true
AND sb.file_hash IS NOT DISTINCT FROM e.file_hash
AND sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash
AND sb.metadata_hash IS DISTINCT FROM e.metadata_hash
`);
});
}
@@ -268,14 +330,29 @@ export class KoboSyncService {
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), inArray(schema.koboSnapshotBooks.bookId, pageIds)));
const entitlements: unknown[] = [];
const identitiesById = await this.bookIdentityService.findByBookIds(
userId,
page.map((row) => row.bookId),
);
const legacyRemovalCompletedBookIds: number[] = [];
const booksById = await this.fetchEligibleBooksByIds(
userId,
page.filter((row) => !row.pendingDelete).map((row) => row.bookId),
false,
);
for (const row of page) {
if (row.pendingDelete) {
entitlements.push(this.buildRemovedEntitlement(row.bookId));
const identity = identitiesById.get(row.bookId) ?? null;
if (identity) {
if (identity.needsLegacyNumericRemoval) {
entitlements.push(this.buildRemovedEntitlement(row.bookId));
legacyRemovalCompletedBookIds.push(row.bookId);
}
entitlements.push(this.buildRemovedEntitlement(identity.entitlementId));
} else {
entitlements.push(this.buildRemovedEntitlement(row.bookId));
}
await this.db
.delete(schema.koboSnapshotBooks)
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot.id), eq(schema.koboSnapshotBooks.bookId, row.bookId)));
@@ -285,13 +362,19 @@ export class KoboSyncService {
const book = booksById.get(row.bookId) ?? null;
if (!book) continue;
if (row.isNew) {
const readingState = (await this.readingStateService.getRawState(userId, row.bookId)) ?? this.buildDefaultReadingState(row.bookId);
const readingState = await this.readingStateService.getRawState(userId, row.bookId);
const defaultReadingState = readingState ?? this.buildDefaultReadingState(book.koboEntitlementId);
if (row.isNew || book.needsLegacyNumericRemoval) {
if (book.needsLegacyNumericRemoval) {
entitlements.push(this.buildRemovedEntitlement(row.bookId));
legacyRemovalCompletedBookIds.push(row.bookId);
}
entitlements.push({
NewEntitlement: {
BookEntitlement: this.buildBookEntitlement(book),
BookMetadata: this.buildBookMetadata(book, deviceToken, baseUrl),
ReadingState: readingState,
ReadingState: defaultReadingState,
},
});
} else {
@@ -301,9 +384,18 @@ export class KoboSyncService {
BookMetadata: this.buildBookMetadata(book, deviceToken, baseUrl),
},
});
if (readingState) {
entitlements.push({
ChangedReadingState: {
ReadingState: readingState,
},
});
}
}
}
await this.bookIdentityService.markLegacyNumericRemovalComplete(userId, legacyRemovalCompletedBookIds);
return { entitlements, hasMore, syncToken };
}
@@ -328,6 +420,8 @@ export class KoboSyncService {
}
const now = new Date().toISOString();
const allEligibleBookIds = [...eligibleIds];
const identitiesById = await this.bookIdentityService.ensureForBooks(userId, allEligibleBookIds, false);
return collections.map((col) => {
const bookIds = (booksByCollection.get(col.id) ?? []).filter((id) => eligibleIds.has(id));
return {
@@ -338,14 +432,17 @@ export class KoboSyncService {
Created: now,
LastModified: now,
Type: 'UserTag',
Items: bookIds.map((bookId) => ({ RevisionId: String(bookId), Type: 'ProductRevisionTagItem' })),
Items: bookIds.flatMap((bookId) => {
const identity = identitiesById.get(bookId);
return identity ? [{ RevisionId: identity.entitlementId, Type: 'ProductRevisionTagItem' }] : [];
}),
},
},
};
});
}
private buildRemovedEntitlement(bookId: number) {
private buildRemovedEntitlement(bookId: number | string) {
const id = String(bookId);
const now = new Date().toISOString();
return {
@@ -371,7 +468,7 @@ export class KoboSyncService {
}
private buildBookEntitlement(book: KoboBookEntry) {
const id = String(book.bookId);
const id = book.koboEntitlementId;
const addedAt = book.addedAt.toISOString();
const updatedAt = book.updatedAt.toISOString();
return {
@@ -392,9 +489,8 @@ export class KoboSyncService {
}
private buildBookMetadata(book: KoboBookEntry, deviceToken: string, baseUrl: string) {
const id = String(book.bookId);
const format = book.fileFormat.toLowerCase() === 'pdf' ? 'PDF' : 'EPUB3';
const downloadUrl = `${baseUrl}/api/v1/kobo/${deviceToken}/v1/books/${book.bookId}/download`;
const id = book.koboEntitlementId;
const downloadUrl = `${baseUrl}/api/v1/kobo/${deviceToken}/v1/books/${id}/download`;
const slug = book.title ? book.title.toLowerCase().replace(/[^a-z0-9]/g, '-') : id;
const publicationDate = book.publishedYear ? new Date(Date.UTC(book.publishedYear, 0, 1)).toISOString() : null;
const publisher = book.publisher ? { Name: book.publisher, Imprint: book.publisher } : null;
@@ -407,8 +503,6 @@ export class KoboSyncService {
}
: { Id: '', Name: '', Number: '', NumberFloat: 0.0 };
const coverImageId = book.metadataUpdatedAt ? `${id}-${book.metadataUpdatedAt.getTime()}` : id;
return {
CrossRevisionId: id,
RevisionId: id,
@@ -421,7 +515,7 @@ export class KoboSyncService {
PublicationDate: publicationDate,
Language: book.language ?? 'en',
Genre: '00000000-0000-0000-0000-000000000001',
CoverImageId: coverImageId,
CoverImageId: book.koboCoverImageId,
Contributors: book.authors,
ContributorRoles: [],
Series: series,
@@ -434,12 +528,12 @@ export class KoboSyncService {
PhoneticPronunciations: {},
CurrentDisplayPrice: { TotalAmount: 0, CurrencyCode: 'USD' },
CurrentLoveDisplayPrice: { TotalAmount: 0 },
DownloadUrls: [{ Format: format, Size: book.fileSizeBytes ?? 0, Url: downloadUrl, Platform: 'Generic', DrmType: 'None' }],
DownloadUrls: [{ Format: book.deliveryFormat, Size: book.fileSizeBytes ?? 0, Url: downloadUrl, Platform: 'Generic', DrmType: 'None' }],
};
}
private buildDefaultReadingState(bookId: number) {
const id = String(bookId);
private buildDefaultReadingState(entitlementId: string) {
const id = entitlementId;
const now = new Date().toISOString();
return {
EntitlementId: id,
@@ -448,7 +542,7 @@ export class KoboSyncService {
PriorityTimestamp: now,
StatusInfo: { LastModified: now, Status: 'ReadyToRead', TimesStartedReading: 0 },
Statistics: { LastModified: now },
CurrentBookmark: { LastModified: now, ProgressPercent: 0, ContentSourceProgressPercent: 0 },
CurrentBookmark: { LastModified: now, ProgressPercent: 0 },
};
}
@@ -458,6 +552,8 @@ export class KoboSyncService {
seriesName: string | null;
seriesIndex: number | null;
metadataUpdatedAt: Date | null;
entitlementId: string;
coverImageId: string;
}): string {
const metaStr = [
params.title ?? '',
@@ -465,10 +561,56 @@ export class KoboSyncService {
params.seriesName ?? '',
String(params.seriesIndex ?? ''),
String(params.metadataUpdatedAt ?? ''),
params.entitlementId,
params.coverImageId,
].join('|');
return createHash('sha256').update(metaStr).digest('hex').slice(0, 16);
}
private buildDeliveryHash(format: KoboDeliveryFormat, hyphenate: boolean): string {
return createHash('sha256')
.update([format, format === 'KEPUB' && hyphenate ? 'hyphenate' : 'plain'].join('|'))
.digest('hex')
.slice(0, 16);
}
private getDeliveryInfo(fileFormat: string | null, fileSizeBytes: number | null, settings: KoboDeliverySettings): KoboDeliveryInfo {
const normalizedFormat = (fileFormat ?? 'epub').toLowerCase();
if (normalizedFormat === 'pdf') {
return { format: 'PDF', hash: this.buildDeliveryHash('PDF', false) };
}
if (normalizedFormat === 'epub') {
const limitBytes = settings.kepubConversionLimitMb * 1024 * 1024;
const withinLimit = !fileSizeBytes || fileSizeBytes <= limitBytes;
if (settings.convertToKepub && withinLimit) {
return { format: 'KEPUB', hash: this.buildDeliveryHash('KEPUB', settings.forceEnableHyphenation) };
}
}
return { format: 'EPUB3', hash: this.buildDeliveryHash('EPUB3', false) };
}
private async getDeliverySettings(userId: number): Promise<KoboDeliverySettings> {
const settings = await this.db.query.koboSyncSettings.findFirst({
where: eq(schema.koboSyncSettings.userId, userId),
columns: {
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: true,
twoWayProgressSync: true,
},
});
return {
convertToKepub: settings?.twoWayProgressSync ? true : (settings?.convertToKepub ?? true),
forceEnableHyphenation: settings?.forceEnableHyphenation ?? false,
kepubConversionLimitMb: settings?.kepubConversionLimitMb ?? 100,
};
}
private async buildEligibleBooksWhereClause(userId: number): Promise<SQL | undefined> {
const [accessibleLibraryIds, contentFilters] = await Promise.all([
this.bookAccessService.getAccessibleLibraryIds(userId),
@@ -503,9 +645,10 @@ export class KoboSyncService {
);
}
private async fetchEligibleSnapshotRows(userId: number): Promise<EligibleSnapshotRow[]> {
private async fetchEligibleSnapshotRows(userId: number, needsLegacyNumericRemovalForNewMappings: boolean): Promise<EligibleSnapshotRow[]> {
const whereClause = await this.buildEligibleBooksWhereClause(userId);
if (!whereClause) return [];
const deliverySettings = await this.getDeliverySettings(userId);
const rows = await this.db
.select({
@@ -514,6 +657,8 @@ export class KoboSyncService {
seriesName: schema.bookMetadata.seriesName,
seriesIndex: schema.bookMetadata.seriesIndex,
metadataUpdatedAt: schema.bookMetadata.updatedAt,
fileFormat: schema.bookFiles.format,
fileSizeBytes: schema.bookFiles.sizeBytes,
fileHash: schema.bookFiles.fileHash,
authorNamesCsv: sql<string>`coalesce(string_agg(${schema.authors.name}, ',' ORDER BY ${schema.bookAuthors.displayOrder}, ${schema.bookAuthors.authorId}), '')`,
})
@@ -529,28 +674,51 @@ export class KoboSyncService {
schema.bookMetadata.seriesName,
schema.bookMetadata.seriesIndex,
schema.bookMetadata.updatedAt,
schema.bookFiles.format,
schema.bookFiles.sizeBytes,
schema.bookFiles.fileHash,
);
return rows.map((row) => ({
bookId: row.bookId,
fileHash: row.fileHash,
metadataHash: this.buildMetadataHash({
title: row.title,
authors: row.authorNamesCsv ? row.authorNamesCsv.split(',').filter((name) => name.length > 0) : [],
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
metadataUpdatedAt: row.metadataUpdatedAt,
}),
}));
const identitiesById = await this.bookIdentityService.ensureForBooks(
userId,
rows.map((row) => row.bookId),
needsLegacyNumericRemovalForNewMappings,
);
return rows.map((row) => {
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings);
const identity = identitiesById.get(row.bookId);
const coverImageId = identity
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt)
: String(row.bookId);
return {
bookId: row.bookId,
fileHash: row.fileHash,
deliveryHash: delivery.hash,
metadataHash: this.buildMetadataHash({
title: row.title,
authors: row.authorNamesCsv ? row.authorNamesCsv.split(',').filter((name) => name.length > 0) : [],
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
metadataUpdatedAt: row.metadataUpdatedAt,
entitlementId: identity?.entitlementId ?? String(row.bookId),
coverImageId,
}),
};
});
}
private async fetchEligibleBooksByIds(userId: number, bookIds: number[]): Promise<Map<number, KoboBookEntry>> {
private async fetchEligibleBooksByIds(
userId: number,
bookIds: number[],
needsLegacyNumericRemovalForNewMappings: boolean,
): Promise<Map<number, KoboBookEntry>> {
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) return new Map();
const whereClause = await this.buildEligibleBooksWhereClause(userId);
if (!whereClause) return new Map();
const deliverySettings = await this.getDeliverySettings(userId);
const rows = await this.db
.select({
@@ -612,11 +780,19 @@ export class KoboSyncService {
collectionsByBook.set(row.bookId, names);
}
const identitiesById = await this.bookIdentityService.ensureForBooks(userId, fetchedBookIds, needsLegacyNumericRemovalForNewMappings);
const byId = new Map<number, KoboBookEntry>();
for (const row of rows) {
const authors = authorsByBook.get(row.bookId) ?? [];
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings);
const identity = identitiesById.get(row.bookId);
if (!identity) continue;
const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt);
byId.set(row.bookId, {
bookId: row.bookId,
koboEntitlementId: identity.entitlementId,
koboCoverImageId: coverImageId,
needsLegacyNumericRemoval: identity.needsLegacyNumericRemoval,
title: row.title ?? `Book ${row.bookId}`,
authors,
description: row.description,
@@ -628,12 +804,15 @@ export class KoboSyncService {
fileFormat: row.fileFormat ?? 'epub',
fileSizeBytes: row.fileSizeBytes,
fileHash: row.fileHash,
deliveryFormat: delivery.format,
metadataHash: this.buildMetadataHash({
title: row.title,
authors,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
metadataUpdatedAt: row.metadataUpdatedAt,
entitlementId: identity.entitlementId,
coverImageId,
}),
metadataUpdatedAt: row.metadataUpdatedAt,
collectionNames: collectionsByBook.get(row.bookId) ?? [],
@@ -177,6 +177,7 @@ describe('KoreaderRepository', () => {
percentage: 41.25,
cfi: null,
pageNumber: null,
koreaderProgress: null,
}),
}),
);
@@ -153,11 +153,10 @@ export class KoreaderRepository {
// Deliberately do NOT update updatedAt here. reading_progress.updatedAt must only
// change when the web reader writes it, so getProgress can use it as an accurate
// "last web-reader sync time" for comparison against koreader_device_progress.updatedAt.
// KOReader sends percentage + XPointer, while the web reader stores CFI. If we keep a
// stale CFI from a previous web session, the web reader may resume at an older location
// even when KOReader synced newer percentage. Clear incompatible web locator fields so
// the reader resumes from percentage fallback.
set: { percentage, cfi: null, pageNumber: null, updatedAt: sql`"reading_progress"."updated_at"` },
// KOReader sends percentage + XPointer, while the web reader stores CFI and its own
// KOReader-compatible XPointer. If we keep stale web locator fields, clients may resume
// at an older location even when KOReader synced newer percentage.
set: { percentage, cfi: null, pageNumber: null, koreaderProgress: null, updatedAt: sql`"reading_progress"."updated_at"` },
});
}
@@ -453,6 +453,28 @@ describe('KoreaderService', () => {
expect(mockChapterService.parseChapterIndexFromCfi).toHaveBeenCalledWith('epubcfi(/6/4!/4/2/2:10)');
});
it('returns exact web reader KOReader XPointer when it is stored', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({
percentage: 50,
cfi: 'epubcfi(/6/4!/4/2/2:10)',
koreaderProgress: '/body/DocFragment[2]/body/p[137]/text()[1].0',
updatedAt: readerTime,
});
await expect(service.getProgress(7, 'doc-hash')).resolves.toEqual({
document: 'doc-hash',
percentage: 0.5,
progress: '/body/DocFragment[2]/body/p[137]/text()[1].0',
device: 'web',
device_id: 'bookorbit-web',
timestamp: Math.floor(readerTime.getTime() / 1000),
});
expect(mockChapterService.parseChapterIndexFromCfi).not.toHaveBeenCalled();
});
it('returns null XPointer when chapter service cannot parse CFI spine index', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
@@ -171,12 +171,8 @@ export class KoreaderService {
}
if (readingProg) {
// Convert the web reader's CFI spine index to a KOReader-compatible XPointer chapter start.
// The CFI encodes the spine item index directly (no file I/O needed — spine data is
// pre-computed in book_file_chapters during scan). KOReader will navigate to the
// beginning of the correct chapter. Percentage drives fine-grained position within it.
let xpointer: string | null = null;
if (readingProg.cfi) {
let xpointer = readingProg.koreaderProgress ?? null;
if (!xpointer && readingProg.cfi) {
const chapterIndex = this.chapterService.parseChapterIndexFromCfi(readingProg.cfi);
if (chapterIndex !== null && chapterIndex >= 0) {
xpointer = `/body/DocFragment[${chapterIndex + 1}]/body`;
@@ -1,9 +1,11 @@
import 'reflect-metadata';
vi.mock('../../book/book.module', () => ({ BookModule: class BookModule {} }));
vi.mock('../../kobo/kobo.module', () => ({ KoboModule: class KoboModule {} }));
vi.mock('../../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
import { BookModule } from '../../book/book.module';
import { KoboModule } from '../../kobo/kobo.module';
import { LibraryModule } from '../../library/library.module';
import { EpubController } from './epub.controller';
import { EpubModule } from './epub.module';
@@ -11,7 +13,7 @@ import { EpubService } from './epub.service';
describe('EpubModule', () => {
it('registers expected imports/controllers/providers', () => {
expect(Reflect.getMetadata('imports', EpubModule)).toEqual([BookModule, LibraryModule]);
expect(Reflect.getMetadata('imports', EpubModule)).toEqual([BookModule, LibraryModule, KoboModule]);
expect(Reflect.getMetadata('controllers', EpubModule)).toEqual([EpubController]);
expect(Reflect.getMetadata('providers', EpubModule)).toEqual([EpubService]);
});
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../../book/book.module';
import { KoboModule } from '../../kobo/kobo.module';
import { LibraryModule } from '../../library/library.module';
import { EpubController } from './epub.controller';
import { EpubService } from './epub.service';
@Module({
imports: [BookModule, LibraryModule],
imports: [BookModule, LibraryModule, KoboModule],
controllers: [EpubController],
providers: [EpubService],
})
@@ -156,15 +156,27 @@ describe('EpubService', () => {
const libraryService = {
verifyUserAccess: vi.fn(),
};
const koboSettingsService = {
getSettings: vi.fn(),
};
const kepubConversionService = {
getKepubPath: vi.fn(),
};
let service: EpubService;
beforeEach(() => {
vi.resetAllMocks();
service = new EpubService(bookReadService as any, libraryService as any);
service = new EpubService(bookReadService as any, libraryService as any, koboSettingsService as any, kepubConversionService as any);
bookReadService.findLibraryIdByBookId.mockResolvedValue(3);
bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: '/books/book.epub' }]);
bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: '/books/book.epub', sizeBytes: null }]);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
koboSettingsService.getSettings.mockResolvedValue({
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
});
mockStat.mockResolvedValue({ mtimeMs: 100 } as Awaited<ReturnType<typeof stat>>);
});
@@ -337,7 +349,7 @@ describe('EpubService', () => {
});
it('uses explicit fileId path when valid and skips primary-file lookup', async () => {
bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub' });
bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub', fileHash: 'hash1', sizeBytes: 123 });
mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any);
await service.getBookInfo(5, 88, user);
@@ -346,6 +358,64 @@ describe('EpubService', () => {
expect(bookReadService.findPrimaryFilesByBookIds).not.toHaveBeenCalled();
});
it('uses the converted kepub path for reader endpoints when two-way Kobo progress sync is enabled', async () => {
bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub', fileHash: 'hash1', sizeBytes: 123 });
koboSettingsService.getSettings.mockResolvedValueOnce({
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
});
kepubConversionService.getKepubPath.mockResolvedValue('/app-data/.kepub-cache/5/hash1-hyph.kepub.epub');
mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any);
await service.getBookInfo(5, 88, user);
expect(kepubConversionService.getKepubPath).toHaveBeenCalledWith({
sourcePath: '/books/alt.epub',
fileHash: 'hash1',
bookId: 5,
hyphenate: true,
});
expect(mockStat).toHaveBeenCalledWith('/app-data/.kepub-cache/5/hash1-hyph.kepub.epub');
expect(mockOpenFile).toHaveBeenCalledWith('/app-data/.kepub-cache/5/hash1-hyph.kepub.epub');
});
it('falls back to the original EPUB path when Kobo settings lookup fails', async () => {
bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub', fileHash: 'hash1', sizeBytes: 123 });
koboSettingsService.getSettings.mockRejectedValueOnce(new Error('settings unavailable'));
mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any);
await service.getBookInfo(5, 88, user);
expect(kepubConversionService.getKepubPath).not.toHaveBeenCalled();
expect(mockStat).toHaveBeenCalledWith('/books/alt.epub');
expect(mockOpenFile).toHaveBeenCalledWith('/books/alt.epub');
});
it('falls back to the original EPUB path when KEPUB conversion fails', async () => {
bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub', fileHash: 'hash1', sizeBytes: 123 });
koboSettingsService.getSettings.mockResolvedValueOnce({
convertToKepub: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
});
kepubConversionService.getKepubPath.mockRejectedValueOnce(new Error('conversion failed'));
mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any);
await service.getBookInfo(5, 88, user);
expect(kepubConversionService.getKepubPath).toHaveBeenCalledWith({
sourcePath: '/books/alt.epub',
fileHash: 'hash1',
bookId: 5,
hyphenate: true,
});
expect(mockStat).toHaveBeenCalledWith('/books/alt.epub');
expect(mockOpenFile).toHaveBeenCalledWith('/books/alt.epub');
});
it('evicts oldest cache entry when capacity is reached', () => {
const cache = (service as any).cache as Map<string, any>;
for (let i = 0; i < 50; i += 1) {
+51 -2
View File
@@ -4,7 +4,10 @@ import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
import type { EpubBookInfo, EpubManifestItem, EpubSpineItem, EpubTocItem } from '@bookorbit/types';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { BookReadService } from '../../book/book-read.service';
import { KepubConversionService } from '../../kobo/services/kepub-conversion.service';
import { KoboSettingsService } from '../../kobo/services/kobo-settings.service';
import { LibraryService } from '../../library/library.service';
import type { RequestUser } from '../../../common/types/request-user';
@@ -284,6 +287,8 @@ export class EpubService {
constructor(
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
private readonly koboSettingsService: KoboSettingsService,
private readonly kepubConversionService: KepubConversionService,
) {}
async getBookInfo(bookId: number, fileId: number | undefined, user: RequestUser): Promise<EpubBookInfo> {
@@ -325,12 +330,56 @@ export class EpubService {
const file = await this.bookReadService.findFileById(fileId);
if (!file || file.bookId !== bookId) throw new NotFoundException(`File ${fileId} not found for book ${bookId}`);
if (file.format !== 'epub') throw new NotFoundException(`File ${fileId} is not an EPUB file`);
return file.absolutePath;
return this.resolveReaderEpubPath(user.id, bookId, {
absolutePath: file.absolutePath,
fileHash: file.fileHash,
sizeBytes: file.sizeBytes,
});
}
const [file] = await this.bookReadService.findPrimaryFilesByBookIds([bookId]);
if (!file || file.format !== 'epub') throw new NotFoundException(`No primary EPUB file for book ${bookId}`);
return file.absolutePath;
return this.resolveReaderEpubPath(user.id, bookId, {
absolutePath: file.absolutePath,
sizeBytes: file.sizeBytes,
});
}
private async resolveReaderEpubPath(
userId: number,
bookId: number,
file: { absolutePath: string; fileHash?: string | null; sizeBytes?: number | null },
): Promise<string> {
const start = Date.now();
let settings: Awaited<ReturnType<KoboSettingsService['getSettings']>>;
try {
settings = await this.koboSettingsService.getSettings(userId);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
`[epub.reader_kepub] [fail] bookId=${bookId} userId=${userId} durationMs=${Date.now() - start} errorClass=${error.constructor.name} error="${sanitizeLogValue(error.message)}" - Kobo settings lookup failed, using original epub`,
);
return file.absolutePath;
}
if (!settings.twoWayProgressSync || !settings.convertToKepub) return file.absolutePath;
const limitBytes = settings.kepubConversionLimitMb * 1024 * 1024;
if (file.sizeBytes && file.sizeBytes > limitBytes) return file.absolutePath;
try {
return await this.kepubConversionService.getKepubPath({
sourcePath: file.absolutePath,
fileHash: file.fileHash,
bookId,
hyphenate: settings.forceEnableHyphenation,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
`[epub.reader_kepub] [fail] bookId=${bookId} userId=${userId} durationMs=${Date.now() - start} errorClass=${error.constructor.name} error="${sanitizeLogValue(error.message)}" - kepub conversion failed, using original epub`,
);
return file.absolutePath;
}
}
private async getCachedEntry(epubPath: string): Promise<CacheEntry> {