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