feat(reader): add grouped reading progress routes

Add GET and PUT /reading-progress and DELETE /reading-progress/:fileId: positions grouped by
kind, up to 25 files saved in one request, and an optional editedAt so the server keeps the newer
of two saves. Move the web reader and book page to them, and list reading-progress in
SERVER_FEATURES for the iOS and Android apps, which adopt the routes in the same release.
This commit is contained in:
neon
2026-10-22 17:28:07 +00:00
parent d44444823f
commit 9e49f496f1
25 changed files with 1116 additions and 110 deletions
@@ -144,6 +144,38 @@ function makeApiResponse(data: unknown, ok = true): Response {
} as Response
}
type FlatProgressRow = {
fileId: number
percentage: number
cfi?: string | null
pageNumber?: number | null
positionSeconds?: number | null
mediaOverlayFragment?: string | null
mediaOverlaySectionIndex?: number | null
updatedAt?: string | null
}
/** A `GET /reading-progress?bookId=` answer holding these files. */
function progressList(rows: FlatProgressRow[]) {
return {
items: rows.map((row) => ({
fileId: row.fileId,
percentage: row.percentage,
updatedAt: row.updatedAt ?? null,
text: { cfi: row.cfi ?? null, page: row.pageNumber ?? null, updatedAt: null },
narration: {
seconds: row.positionSeconds ?? null,
fragment: row.mediaOverlayFragment ?? null,
sectionIndex: row.mediaOverlaySectionIndex ?? null,
percentage: null,
updatedAt: null,
},
kobo: { source: null, type: null, value: null, contentPercent: null },
koreader: null,
})),
}
}
describe('DetailsTab - missing state', () => {
it('renders the amber warning banner', () => {
const wrapper = mount(DetailsTab, {
@@ -482,8 +514,8 @@ describe('DetailsTab - present state', () => {
it('formats tiny non-zero progress as <1%', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/books/1/progress') {
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }])
if (input === '/api/v1/reading-progress?bookId=1') {
return makeApiResponse(progressList([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }]))
}
if (input === '/api/v1/collections/membership') {
return makeApiResponse([])
@@ -518,8 +550,8 @@ describe('DetailsTab - present state', () => {
it('formats near-complete progress as >99%', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/books/1/progress') {
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }])
if (input === '/api/v1/reading-progress?bookId=1') {
return makeApiResponse(progressList([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }]))
}
if (input === '/api/v1/collections/membership') {
return makeApiResponse([])
@@ -554,19 +586,21 @@ describe('DetailsTab - present state', () => {
it('shows EPUB3 narration progress when only media-overlay position is stored', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/books/1/progress') {
return makeApiResponse([
{
fileId: 101,
cfi: null,
pageNumber: null,
percentage: 0,
positionSeconds: 900,
mediaOverlayFragment: 'OPS/chapter.xhtml#s12',
mediaOverlaySectionIndex: 3,
updatedAt: null,
},
])
if (input === '/api/v1/reading-progress?bookId=1') {
return makeApiResponse(
progressList([
{
fileId: 101,
cfi: null,
pageNumber: null,
percentage: 0,
positionSeconds: 900,
mediaOverlayFragment: 'OPS/chapter.xhtml#s12',
mediaOverlaySectionIndex: 3,
updatedAt: null,
},
]),
)
}
if (input === '/api/v1/collections?bookIds=1') {
return makeApiResponse([])
@@ -606,12 +640,12 @@ describe('DetailsTab - present state', () => {
{ fileId: 101, cfi: null, pageNumber: null, percentage: 22, updatedAt: null },
]
vi.mocked(api).mockImplementation(async (input, init) => {
if (input === '/api/v1/books/files/101/progress' && init?.method === 'DELETE') {
if (input === '/api/v1/reading-progress/101' && init?.method === 'DELETE') {
progressRows = []
return makeApiResponse({})
}
if (input === '/api/v1/books/1/progress') {
return makeApiResponse(progressRows)
if (input === '/api/v1/reading-progress?bookId=1') {
return makeApiResponse(progressList(progressRows))
}
if (input === '/api/v1/collections/membership') {
return makeApiResponse([])
@@ -649,7 +683,7 @@ describe('DetailsTab - present state', () => {
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(vi.mocked(api)).toHaveBeenCalledWith('/api/v1/books/files/101/progress', { method: 'DELETE' })
expect(vi.mocked(api)).toHaveBeenCalledWith('/api/v1/reading-progress/101', { method: 'DELETE' })
confirmSpy.mockRestore()
})
})
@@ -6,6 +6,7 @@ import type { BookDetail, BookReadingSessionStats, ReadStatus, UserBookStatus }
import { isAudioFormat } from '@bookorbit/types'
import { bookFormatEntries, formatKeyName } from '@/features/book/lib/book-formats'
import { api } from '@/lib/api'
import { fetchBookProgress } from '@/lib/reading-progress'
import { formatDate, formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { STATUS_COLORS, STATUS_ICONS, STATUS_OPTIONS, useBookStatus } from '@/features/book/composables/useBookStatus'
@@ -186,18 +187,15 @@ async function loadProgress(silent = false) {
const bookId = props.book.id
const hasAudio = props.book.files.some((file) => file.format != null && isAudioFormat(file.format))
try {
const [progressRes, audioRes] = await Promise.all([
api(`/api/v1/books/${bookId}/progress`).catch(() => null),
const [progressRows, audioRes] = await Promise.all([
fetchBookProgress(bookId).catch(() => null),
hasAudio ? api(`/api/v1/audiobooks/${bookId}/playback-state`).catch(() => null) : Promise.resolve(null),
])
if (bookId !== props.book.id) return
let max = 0
if (progressRes?.ok) {
const rows = (await progressRes.json()) as { fileId: number; percentage: number }[]
for (const row of rows) {
if (Number.isFinite(row.percentage)) max = Math.max(max, row.percentage)
}
for (const row of progressRows ?? []) {
if (Number.isFinite(row.percentage)) max = Math.max(max, row.percentage)
}
if (audioRes?.ok) {
const data = (await audioRes.json()) as { percentage?: number } | null
@@ -51,6 +51,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { api } from '@/lib/api'
import { clearReadingProgress, fetchBookProgress } from '@/lib/reading-progress'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
@@ -1135,9 +1136,7 @@ async function handleResetFileProgress(row: EditionProgress) {
setFileResetting(fileId, true)
try {
const res =
fileId < 0
? await api(`/api/v1/audiobooks/${props.book.id}/playback-state`, { method: 'DELETE' })
: await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
fileId < 0 ? await api(`/api/v1/audiobooks/${props.book.id}/playback-state`, { method: 'DELETE' }) : await clearReadingProgress(fileId)
if (!res.ok) throw new Error('Failed to reset file progress')
await loadSupplemental()
} finally {
@@ -1152,7 +1151,7 @@ async function loadSupplemental() {
supplementalLoading.value = true
const hasAudio = props.book.files.some((f) => f.format && FORMAT_TO_GROUP[f.format] === 'audio')
try {
const progressPromise = api(`/api/v1/books/${props.book.id}/progress`).catch(() => null)
const progressPromise = fetchBookProgress(props.book.id).catch(() => null)
const audioProgressPromise = hasAudio ? api(`/api/v1/audiobooks/${props.book.id}/playback-state`).catch(() => null) : Promise.resolve(null)
const collectionsPromise = api('/api/v1/collections/membership', {
method: 'POST',
@@ -1162,7 +1161,7 @@ async function loadSupplemental() {
const koboPromise = canViewKobo.value ? api(`/api/v1/books/${props.book.id}/kobo-state`) : Promise.resolve(null)
const koreaderProgressPromise = canViewKoreader.value ? fetchKoreaderProgress(props.book.id) : Promise.resolve()
const [progressRes, audioProgressRes, collectionsRes, koboRes] = await Promise.all([
const [fetchedProgressRows, audioProgressRes, collectionsRes, koboRes] = await Promise.all([
progressPromise,
audioProgressPromise,
collectionsPromise,
@@ -1172,7 +1171,7 @@ async function loadSupplemental() {
if (requestId !== supplementalRequestId) return
const progressRows: FileProgressRow[] = progressRes && progressRes.ok ? ((await progressRes.json()) as FileProgressRow[]) : []
const progressRows: FileProgressRow[] = fetchedProgressRows ?? []
const progressMap: Record<number, FileProgress> = {}
for (const row of progressRows) {
if (!Number.isFinite(row.fileId)) continue
@@ -199,6 +199,7 @@ describe('DetailsTab cover surface', () => {
})
}
if (url.includes('/koreader/books/')) return response(null)
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
if (url.includes('/progress')) return response([])
return response({})
})
@@ -531,6 +532,7 @@ describe('DetailsTab cover surface', () => {
})
}
if (url.includes('/koreader/books/')) return response(null)
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
if (url.includes('/progress')) return response([])
return response({})
})
@@ -697,6 +699,7 @@ describe('DetailsTab cover surface', () => {
return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
}
if (url.includes('/koreader/books/')) return response(null)
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
if (url.includes('/progress')) return response([])
return response({})
})
@@ -705,7 +708,7 @@ describe('DetailsTab cover surface', () => {
await flushPromises()
expect(mocks.api).toHaveBeenNthCalledWith(1, '/api/v1/books/12/reset-reading-state', { method: 'POST' })
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/progress')
expect(mocks.api).toHaveBeenCalledWith('/api/v1/reading-progress?bookId=12')
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/kobo-state')
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/12/progress')
expect(wrapper.emitted('saved')).toEqual([
@@ -795,6 +798,7 @@ describe('DetailsTab cover surface', () => {
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
if (url.includes('/koreader/books/')) return response(null)
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
if (url.includes('/progress')) return response([])
return response({})
})
@@ -897,6 +901,7 @@ describe('DetailsTab cover surface', () => {
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
if (url.includes('/koreader/books/')) return response(null)
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
if (url.includes('/progress')) return response([])
return response({})
})
@@ -241,6 +241,7 @@ function routeApi(
if (url.includes('/sessions')) {
return Promise.resolve(jsonResponse({ items: sessions, total: sessions.length, page: 1, pageSize: 25, stats: makeStats(sessions) }))
}
if (url.startsWith('/api/v1/reading-progress')) return Promise.resolve(jsonResponse({ items: [] }))
if (url.includes('/progress')) return Promise.resolve(jsonResponse([]))
return Promise.resolve(jsonResponse({}))
}
@@ -508,7 +509,8 @@ describe('ReadingLogTab', () => {
held = []
return Promise.resolve({ ok: true, status: 204, json: async () => ({}) } as Response)
}
if (url === '/api/v1/books/10/progress') return Promise.resolve(jsonResponse(held.length ? [] : [{ fileId: 17, percentage: 26.67 }]))
if (url === '/api/v1/reading-progress?bookId=10')
return Promise.resolve(jsonResponse({ items: held.length ? [] : [{ fileId: 17, percentage: 26.67 }] }))
return routeApi({ sessions: [makeSession({ source: 'koreader' })], koreader: () => makeKoreaderProgress({ heldByReset: held }) })(input)
})
const wrapper = mountTab()
@@ -523,7 +525,7 @@ describe('ReadingLogTab', () => {
expect(releaseCall?.[1]).toMatchObject({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-1' }) })
const urls = mocks.api.mock.calls.map((call) => String(call[0]))
expect(urls).toContain('/api/v1/books/10/progress')
expect(urls).toContain('/api/v1/reading-progress?bookId=10')
expect(urls.some((url) => url.startsWith('/api/v1/books/10/sessions?'))).toBe(true)
expect(urls.some((url) => url.includes('/api/v1/books/10/reading-attempts'))).toBe(true)
expect(wrapper.emitted('refetch')).toHaveLength(1)
@@ -10,10 +10,10 @@ import {
type BookDetailFile,
} from '@bookorbit/types'
import { bookFormatEntries, fileFormatKey } from '@/features/book/lib/book-formats'
import { api } from '@/lib/api'
import { fetchBookProgress } from '@/lib/reading-progress'
import { applyCommonStem } from '@/features/book/lib/filename-stem'
/** Per-file reading position. Mirrors the row shape `GET /api/v1/books/:id/progress` returns. */
/** Per-file reading position, as `GET /api/v1/reading-progress?bookId=` reports it. */
export type FileProgress = {
percentage: number
updatedAt: string | null
@@ -205,10 +205,8 @@ export function useBookFileTree(book: Ref<BookDetail>) {
async function loadProgress() {
const requestId = ++progressRequestId
try {
const response = await api(`/api/v1/books/${book.value.id}/progress`)
if (!response.ok) return
const rows = (await response.json()) as { fileId: number; percentage: number; updatedAt: string | null }[]
if (requestId !== progressRequestId) return
const rows = await fetchBookProgress(book.value.id)
if (!rows || requestId !== progressRequestId) return
const next: Record<number, FileProgress> = {}
for (const row of rows) {
if (!Number.isFinite(row.fileId)) continue
@@ -197,20 +197,17 @@ describe('useReaderProgress', () => {
await vi.advanceTimersByTimeAsync(2500)
const saveCalls = apiMock.mock.calls.filter(
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/progress') && (c[1] as { method?: string })?.method === 'POST',
(c: unknown[]) => typeof c[0] === 'string' && c[0] === '/api/v1/reading-progress' && (c[1] as { method?: string })?.method === 'PUT',
)
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)
const saveBody = JSON.parse((saveCall[1] as { body: string }).body).writes[0]
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',
kobo: { source: 'OEBPS/html/ch3.xhtml', type: 'KoboSpan', value: 'kobo.25.1', contentPercent: 25 },
koreader: '/body/DocFragment[3]/body/p[4]/text()[1].2',
}),
)
@@ -238,20 +235,17 @@ describe('useReaderProgress', () => {
await progress.save()
const saveCall = apiMock.mock.calls.find(
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/progress') && (c[1] as { method?: string })?.method === 'POST',
(c: unknown[]) => typeof c[0] === 'string' && c[0] === '/api/v1/reading-progress' && (c[1] as { method?: string })?.method === 'PUT',
)
if (!saveCall) throw new Error('Expected progress save call')
const saveBody = JSON.parse((saveCall[1] as { body: string }).body)
const saveBody = JSON.parse((saveCall[1] as { body: string }).body).writes[0]
expect(saveBody).toEqual(
expect.objectContaining({
cfi: 'epubcfi(/6/38!/4,/488/2/1:604,/512/2/1:89)',
text: { cfi: 'epubcfi(/6/38!/4,/488/2/1:604,/512/2/1:89)', page: null },
percentage: 69.604585363819,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: 87.5,
koreaderProgress: '/body/DocFragment[19]/body/p[222]/font/text().604',
kobo: { source: null, type: null, value: null, contentPercent: 87.5 },
koreader: '/body/DocFragment[19]/body/p[222]/font/text().604',
}),
)
} finally {
@@ -8,17 +8,71 @@ import { formatTimeRemaining, useReaderProgress, type NewerPosition } from './us
const mockApi = vi.mocked(api)
/** A `GET /reading-progress` answer for file 42, built from the flat field names the reader works in. */
function makeOkResponse(data: Record<string, unknown>) {
return { ok: true, json: async () => data } as Response
const entry = {
fileId: 42,
percentage: data.percentage,
updatedAt: data.updatedAt ?? null,
text: { cfi: data.cfi, page: data.pageNumber, updatedAt: data.textUpdatedAt },
narration: {
seconds: data.positionSeconds,
fragment: data.mediaOverlayFragment,
sectionIndex: data.mediaOverlaySectionIndex,
percentage: data.narrationPercentage,
updatedAt: data.narrationUpdatedAt,
},
kobo: {
source: data.koboLocationSource,
type: data.koboLocationType,
value: data.koboLocationValue,
contentPercent: data.koboContentSourceProgressPercent,
},
koreader: data.koreaderProgress,
}
return { ok: true, json: async () => ({ items: [entry] }) } as Response
}
type SentWrite = {
kind?: string
percentage?: number
text?: { cfi?: unknown; page?: unknown }
narration?: { seconds?: unknown; fragment?: unknown; sectionIndex?: unknown }
kobo?: { source?: unknown; type?: unknown; value?: unknown; contentPercent?: unknown }
koreader?: unknown
}
function sentWrite(callIndex = 0): SentWrite {
const [, opts] = mockApi.mock.calls[callIndex]!
const { writes } = JSON.parse((opts as RequestInit).body as string) as { writes: SentWrite[] }
return writes[0]!
}
function makeErrorResponse() {
return { ok: false } as Response
}
/** The write a save sent, read back under the flat field names the reader works in. */
function postedProgressBody(callIndex = 0): Record<string, unknown> {
const [, opts] = mockApi.mock.calls[callIndex]!
return JSON.parse((opts as RequestInit).body as string) as Record<string, unknown>
const write = sentWrite(callIndex)
return {
cfi: write.text?.cfi,
pageNumber: write.text?.page,
percentage: write.percentage,
koboLocationSource: write.kobo?.source,
koboLocationType: write.kobo?.type,
koboLocationValue: write.kobo?.value,
koboContentSourceProgressPercent: write.kobo?.contentPercent,
koreaderProgress: write.koreader,
...(write.narration
? {
positionSeconds: write.narration.seconds,
mediaOverlayFragment: write.narration.fragment,
mediaOverlaySectionIndex: write.narration.sectionIndex,
}
: {}),
source: write.kind,
}
}
describe('formatTimeRemaining', () => {
@@ -95,7 +149,7 @@ describe('useReaderProgress', () => {
await load()
expect(mockApi).toHaveBeenCalledWith('/api/v1/books/files/42/progress')
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress?fileId=42')
})
it('sets state from response data on ok response', async () => {
@@ -260,7 +314,7 @@ describe('useReaderProgress', () => {
describe('positions it did not move to (#1136)', () => {
function postCount() {
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST').length
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT').length
}
async function openWith(saved: Record<string, unknown>) {
@@ -392,7 +446,7 @@ describe('useReaderProgress', () => {
await vi.advanceTimersByTimeAsync(2500)
expect(progress.mediaOverlayFragment.value).toBe('c.xhtml#s40')
expect(mockApi.mock.calls.some(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')).toBe(false)
expect(mockApi.mock.calls.some(([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT')).toBe(false)
})
it('saves its own page as reading when it stays, and does not offer the other again', async () => {
@@ -404,9 +458,12 @@ describe('useReaderProgress', () => {
await progress.keepCurrentPosition(newer)
const posts = mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')
const posts = mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT')
expect(posts).toHaveLength(1)
expect(JSON.parse((posts[0]![1] as RequestInit).body as string)).toMatchObject({ cfi: 'epubcfi(/6/4!/4/2/1:0)', source: 'text' })
expect(JSON.parse((posts[0]![1] as RequestInit).body as string).writes[0]).toMatchObject({
text: { cfi: 'epubcfi(/6/4!/4/2/1:0)' },
kind: 'text',
})
mockApi.mockResolvedValueOnce(elsewhere)
expect(await progress.checkForNewerPosition()).toBeNull()
})
@@ -425,7 +482,7 @@ describe('useReaderProgress', () => {
}
function posts() {
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'PUT')
}
async function narrating(movedBack = vi.fn<(position: NewerPosition) => void>()) {
@@ -499,7 +556,10 @@ describe('useReaderProgress', () => {
await progress.keepCurrentPosition(newer, 'narration')
expect(posts()).toHaveLength(1)
expect(JSON.parse((posts()[0]![1] as RequestInit).body as string)).toMatchObject({ mediaOverlayFragment: 'c.xhtml#s9', source: 'narration' })
expect(JSON.parse((posts()[0]![1] as RequestInit).body as string).writes[0]).toMatchObject({
narration: { fragment: 'c.xhtml#s9' },
kind: 'narration',
})
})
async function savedAt(progress: ReturnType<typeof useReaderProgress>, fragment: string, date: string) {
@@ -706,7 +766,7 @@ describe('useReaderProgress', () => {
expect(mockApi).not.toHaveBeenCalled()
vi.advanceTimersByTime(2000)
expect(mockApi).toHaveBeenCalledWith('/api/v1/books/files/42/progress', expect.objectContaining({ method: 'POST' }))
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress', expect.objectContaining({ method: 'PUT' }))
})
it('does not schedule save when trackingEnabled is false', () => {
@@ -739,7 +799,7 @@ describe('useReaderProgress', () => {
})
describe('save()', () => {
it('calls api POST with all progress fields', async () => {
it('puts the write to the progress route', async () => {
mockApi.mockResolvedValue(makeOkResponse({}))
const elapsedMinutes = ref(0)
const { save } = useReaderProgress(1, 42, elapsedMinutes)
@@ -747,15 +807,42 @@ describe('useReaderProgress', () => {
await save()
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/books/files/42/progress',
'/api/v1/reading-progress',
expect.objectContaining({
method: 'POST',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('"cfi"'),
body: expect.stringContaining('"writes"'),
}),
)
})
it('groups a page turn by position and leaves narration out of it', async () => {
mockApi.mockResolvedValue(makeOkResponse({}))
const elapsedMinutes = ref(0)
const { onRelocate, save } = useReaderProgress(1, 42, elapsedMinutes)
onRelocate({
cfi: 'epubcfi(/6/2)',
fraction: 0.25,
source: 'OEBPS/c2.xhtml',
koboLocationValue: 'kobo.2.1',
koboLocationType: 'KoboSpan',
contentSourceProgressPercent: 40,
koreaderProgress: '/body/p[1]',
} as never)
vi.clearAllTimers()
await save()
expect(sentWrite()).toEqual({
fileId: 42,
kind: 'text',
percentage: 25,
text: { cfi: 'epubcfi(/6/2)', page: null },
kobo: { source: 'OEBPS/c2.xhtml', type: 'KoboSpan', value: 'kobo.2.1', contentPercent: 40 },
koreader: '/body/p[1]',
})
})
it('does not call api when trackingEnabled is false', async () => {
const elapsedMinutes = ref(0)
const { save } = useReaderProgress(1, 42, elapsedMinutes, 0, {
@@ -1,5 +1,5 @@
import { computed, onUnmounted, ref, unref, type MaybeRef, type Ref } from 'vue'
import { api } from '@/lib/api'
import { fetchFileProgress, saveReadingProgress } from '@/lib/reading-progress'
import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate'
export type FooterDisplayMode = 0 | 1 | 2
@@ -143,9 +143,8 @@ export function useReaderProgress(
async function load() {
if (!unref(trackingEnabled)) return
const res = await api(`/api/v1/books/files/${fileId}/progress`)
if (!res.ok) return
const data = await res.json()
const data = await fetchFileProgress(fileId)
if (!data) return
hadSavedPosition =
normalizeString(data.cfi) !== null ||
normalizeString(data.koboLocationValue) !== null ||
@@ -254,9 +253,8 @@ export function useReaderProgress(
const secondsOf = options.narrationSeconds
const fragment = mediaOverlayFragment.value
if (!secondsOf || !fragment) return null
const res = await api(`/api/v1/books/files/${fileId}/progress`).catch(() => null)
if (!res?.ok) return null
const data = (await res.json()) as Record<string, unknown>
const data = await fetchFileProgress(fileId).catch(() => null)
if (!data) return null
const stamp = positionStamp(data)
const serverFragment = normalizeString(data.mediaOverlayFragment)
if (!serverFragment || serverFragment === fragment) return null
@@ -279,29 +277,29 @@ export function useReaderProgress(
}
function postProgress(safePercentage: number) {
return api(`/api/v1/books/files/${fileId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cfi: cfi.value,
pageNumber: normalizePageNumber(pageNumber.value),
percentage: safePercentage,
koboLocationSource: koboLocationSource.value,
koboLocationType: koboLocationType.value,
koboLocationValue: koboLocationValue.value,
koboContentSourceProgressPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
koreaderProgress: koreaderProgress.value,
// Left out of a page turn, which says nothing about narration: sent along, the marker this
// reader read when it opened went back over a newer one another device had saved since.
...(pendingSource.value === 'narration'
? {
positionSeconds: positionSeconds.value,
mediaOverlayFragment: mediaOverlayFragment.value,
mediaOverlaySectionIndex: mediaOverlaySectionIndex.value,
}
: {}),
source: pendingSource.value,
}),
return saveReadingProgress({
fileId,
kind: pendingSource.value,
percentage: safePercentage,
text: { cfi: cfi.value, page: normalizePageNumber(pageNumber.value) },
kobo: {
source: koboLocationSource.value,
type: koboLocationType.value,
value: koboLocationValue.value,
contentPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
},
koreader: koreaderProgress.value,
// Left out of a page turn, which says nothing about narration: sent along, the marker this
// reader read when it opened went back over a newer one another device had saved since.
...(pendingSource.value === 'narration'
? {
narration: {
seconds: positionSeconds.value,
fragment: mediaOverlayFragment.value,
sectionIndex: mediaOverlaySectionIndex.value,
},
}
: {}),
})
}
@@ -312,9 +310,8 @@ export function useReaderProgress(
*/
async function checkForNewerPosition(): Promise<NewerPosition | null> {
if (!unref(trackingEnabled) || saveTimer || saving) return null
const res = await api(`/api/v1/books/files/${fileId}/progress`)
if (!res.ok || saveTimer || saving) return null
const data = (await res.json()) as Record<string, unknown>
const data = await fetchFileProgress(fileId)
if (!data || saveTimer || saving) return null
const stamp = positionStamp(data)
if (stamp <= knownServerStamp) return null
if (normalizeString(data.cfi) === cfi.value && normalizeString(data.mediaOverlayFragment) === mediaOverlayFragment.value) {
@@ -0,0 +1,79 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReadingProgressEntry } from '@bookorbit/types'
vi.mock('@/lib/api', () => ({ api: vi.fn<(...args: unknown[]) => unknown>() }))
import { api } from '@/lib/api'
import { clearReadingProgress, fetchBookProgress, fetchFileProgress, flattenReadingProgress, saveReadingProgress } from '../reading-progress'
const mockApi = vi.mocked(api)
const entry: ReadingProgressEntry = {
fileId: 64,
percentage: 7.99,
updatedAt: '2026-10-22T09:30:00.000Z',
text: { cfi: 'epubcfi(/6/22!/4/108/1:0)', page: null, updatedAt: '2026-10-22T09:30:00.000Z' },
narration: { seconds: 812.4, fragment: 'c9R.xhtml#s12', sectionIndex: 9, percentage: 6, updatedAt: '2026-10-21T09:00:00.000Z' },
kobo: { source: 'OEBPS/c9R.xhtml', type: 'KoboSpan', value: 'kobo.12.3', contentPercent: 50 },
koreader: '/body/DocFragment[10]/body/p[3]',
}
function okJson(body: unknown): Response {
return { ok: true, json: async () => body } as Response
}
describe('reading progress client', () => {
beforeEach(() => {
mockApi.mockReset()
})
it('flattens a grouped entry into the fields the readers work in', () => {
expect(flattenReadingProgress(entry)).toEqual({
fileId: 64,
percentage: 7.99,
updatedAt: '2026-10-22T09:30:00.000Z',
cfi: 'epubcfi(/6/22!/4/108/1:0)',
pageNumber: null,
textUpdatedAt: '2026-10-22T09:30:00.000Z',
positionSeconds: 812.4,
mediaOverlayFragment: 'c9R.xhtml#s12',
mediaOverlaySectionIndex: 9,
narrationPercentage: 6,
narrationUpdatedAt: '2026-10-21T09:00:00.000Z',
koboLocationSource: 'OEBPS/c9R.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.12.3',
koboContentSourceProgressPercent: 50,
koreaderProgress: '/body/DocFragment[10]/body/p[3]',
})
})
it('reads one file, and answers null when the server does not', async () => {
mockApi.mockResolvedValueOnce(okJson({ items: [entry] }))
expect((await fetchFileProgress(64))?.cfi).toBe('epubcfi(/6/22!/4/108/1:0)')
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress?fileId=64')
mockApi.mockResolvedValueOnce({ ok: false } as Response)
expect(await fetchFileProgress(64)).toBeNull()
})
it('reads every file of a book', async () => {
mockApi.mockResolvedValueOnce(okJson({ items: [entry, { ...entry, fileId: 65 }] }))
expect((await fetchBookProgress(60))?.map((row) => row.fileId)).toEqual([64, 65])
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress?bookId=60')
})
it('puts one write and deletes one file', async () => {
mockApi.mockResolvedValue(okJson({}))
await saveReadingProgress({ fileId: 64, kind: 'text', percentage: 8, text: { cfi: 'epubcfi(/6/2)' } })
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ writes: [{ fileId: 64, kind: 'text', percentage: 8, text: { cfi: 'epubcfi(/6/2)' } }] }),
})
await clearReadingProgress(64)
expect(mockApi).toHaveBeenCalledWith('/api/v1/reading-progress/64', { method: 'DELETE' })
})
})
+72
View File
@@ -0,0 +1,72 @@
import type { ReadingProgressEntry, ReadingProgressListResponse, ReadingProgressWrite } from '@bookorbit/types'
import { api } from '@/lib/api'
/** One file's saved position with every field at the top level, the form the readers work in. */
export type FlatReadingProgress = {
fileId: number
percentage: number
updatedAt: string | null
cfi: string | null
pageNumber: number | null
textUpdatedAt: string | null
positionSeconds: number | null
mediaOverlayFragment: string | null
mediaOverlaySectionIndex: number | null
narrationPercentage: number | null
narrationUpdatedAt: string | null
koboLocationSource: string | null
koboLocationType: string | null
koboLocationValue: string | null
koboContentSourceProgressPercent: number | null
koreaderProgress: string | null
}
export function flattenReadingProgress(entry: ReadingProgressEntry): FlatReadingProgress {
return {
fileId: entry.fileId,
percentage: entry.percentage,
updatedAt: entry.updatedAt,
cfi: entry.text?.cfi ?? null,
pageNumber: entry.text?.page ?? null,
textUpdatedAt: entry.text?.updatedAt ?? null,
positionSeconds: entry.narration?.seconds ?? null,
mediaOverlayFragment: entry.narration?.fragment ?? null,
mediaOverlaySectionIndex: entry.narration?.sectionIndex ?? null,
narrationPercentage: entry.narration?.percentage ?? null,
narrationUpdatedAt: entry.narration?.updatedAt ?? null,
koboLocationSource: entry.kobo?.source ?? null,
koboLocationType: entry.kobo?.type ?? null,
koboLocationValue: entry.kobo?.value ?? null,
koboContentSourceProgressPercent: entry.kobo?.contentPercent ?? null,
koreaderProgress: entry.koreader ?? null,
}
}
async function fetchProgress(query: string): Promise<FlatReadingProgress[] | null> {
const res = await api(`/api/v1/reading-progress?${query}`)
if (!res.ok) return null
const body = (await res.json()) as ReadingProgressListResponse
return Array.isArray(body?.items) ? body.items.map(flattenReadingProgress) : null
}
/** Null when the server would not answer. */
export async function fetchFileProgress(fileId: number): Promise<FlatReadingProgress | null> {
return (await fetchProgress(`fileId=${fileId}`))?.[0] ?? null
}
/** Every file of the book, with an empty position for one never opened; null when the server would not answer. */
export function fetchBookProgress(bookId: number): Promise<FlatReadingProgress[] | null> {
return fetchProgress(`bookId=${bookId}`)
}
export function saveReadingProgress(write: ReadingProgressWrite): Promise<Response> {
return api('/api/v1/reading-progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ writes: [write] }),
})
}
export function clearReadingProgress(fileId: number): Promise<Response> {
return api(`/api/v1/reading-progress/${fileId}`, { method: 'DELETE' })
}
+5
View File
@@ -46,6 +46,11 @@ export const SERVER_FEATURES = [
* `/:attemptId/marks` and `PATCH /:attemptId/rating`.
*/
"reading-history",
/**
* `GET`/`PUT /reading-progress` and `DELETE /reading-progress/:fileId`: positions grouped by
* kind, several files saved in one request, and `editedAt` so the newer of two saves wins.
*/
"reading-progress",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];
+1
View File
@@ -74,6 +74,7 @@ export * from "./dictionary";
export * from "./achievement";
export * from "./activity";
export * from "./reading-history";
export * from "./reading-progress";
export * from "./reading-session";
export * from "./reading-session-source-bucket";
export * from "./shared-reading-insights";
+93
View File
@@ -0,0 +1,93 @@
/** Which of a file's two positions a write moved. A write that names neither moved the text one. */
export const READING_POSITION_KINDS = ["text", "narration"] as const;
export type ReadingPositionKind = (typeof READING_POSITION_KINDS)[number];
/** The most writes one `PUT /reading-progress` carries. */
export const READING_PROGRESS_MAX_WRITES = 25;
export interface ReadingTextPosition {
/** EPUB: the CFI of the page start. */
cfi: string | null;
/** PDF and comics: the page, 1-based. */
page: number | null;
/** When the text position last moved. Null on rows written before the two positions were split. */
updatedAt: string | null;
}
/** Where an EPUB3 read-along or a narrated file is, kept apart from the text position. */
export interface ReadingNarrationPosition {
seconds: number | null;
/** The SMIL text fragment being narrated. */
fragment: string | null;
/** The spine section the fragment belongs to. */
sectionIndex: number | null;
percentage: number | null;
updatedAt: string | null;
}
/** The bookmark a Kobo device resumes from. */
export interface ReadingKoboLocation {
/** The spine resource the location sits in. */
source: string | null;
/** Usually `KoboSpan`. */
type: string | null;
value: string | null;
/** Progress within `source`, 0 to 100. */
contentPercent: number | null;
}
export interface ReadingProgressEntry {
fileId: number;
/** 0 to 100. */
percentage: number;
/** When any position on the file was last saved; null when none ever was. */
updatedAt: string | null;
text: ReadingTextPosition;
narration: ReadingNarrationPosition;
kobo: ReadingKoboLocation;
/** The KOReader XPointer for the same place. */
koreader: string | null;
}
/** `GET /reading-progress?fileId=` (one entry) or `?bookId=` (one per file of the book). */
export interface ReadingProgressListResponse {
items: ReadingProgressEntry[];
}
/**
* One position to save. A narration object that is left out keeps the marker the file holds, and
* inside it, a field left out keeps that field while an explicit null clears it.
*/
export interface ReadingProgressWrite {
fileId: number;
kind?: ReadingPositionKind;
percentage: number;
/**
* When the reader made this move, on the server's clock. A position saved after it wins, and the
* write comes back `superseded` with that position instead. Without it the write always lands.
*/
editedAt?: string;
text?: { cfi?: string | null; page?: number | null };
narration?: { seconds?: number | null; fragment?: string | null; sectionIndex?: number | null };
kobo?: { source?: string | null; type?: string | null; value?: string | null; contentPercent?: number | null };
koreader?: string | null;
}
/** `PUT /reading-progress`. Each file appears at most once. */
export interface ReadingProgressWriteRequest {
writes: ReadingProgressWrite[];
}
export type ReadingProgressWriteStatus = "saved" | "superseded";
export interface ReadingProgressWriteResult {
fileId: number;
status: ReadingProgressWriteStatus;
/** What the file holds now: this write's result, or the newer position that superseded it. */
progress: ReadingProgressEntry;
}
/** Results in the order the writes were sent. */
export interface ReadingProgressWriteResponse {
results: ReadingProgressWriteResult[];
}
+4 -1
View File
@@ -21,6 +21,8 @@ import { BookRepository } from './book.repository';
import { BookService } from './book.service';
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
import { ReadingAttemptController } from './reading-attempt.controller';
import { ReadingProgressController } from './reading-progress.controller';
import { ReadingProgressService } from './reading-progress.service';
import { AudiobookEbookProgressSyncService } from './audiobook-ebook-progress-sync.service';
import { DeliveryArtifactService } from './delivery-artifact.service';
import { DeliveryArtifactRepository } from './delivery-artifact.repository';
@@ -34,7 +36,7 @@ describe('BookModule', () => {
});
it('registers expected controller/providers/exports', () => {
expect(Reflect.getMetadata('controllers', BookModule)).toEqual([BookController, ReadingAttemptController]);
expect(Reflect.getMetadata('controllers', BookModule)).toEqual([BookController, ReadingAttemptController, ReadingProgressController]);
expect(Reflect.getMetadata('providers', BookModule)).toEqual([
BookService,
BookRepository,
@@ -48,6 +50,7 @@ describe('BookModule', () => {
DeliveryArtifactRepository,
DeliverySelectionService,
DeliveryHashRegistrationService,
ReadingProgressService,
]);
// AudiolessEpubService is exported so the Kobo download path can strip narration itself,
// without routing through BookService and its RequestUser-based access checks.
+4 -1
View File
@@ -31,6 +31,8 @@ import { BookRepository } from './book.repository';
import { BookService } from './book.service';
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
import { ReadingAttemptController } from './reading-attempt.controller';
import { ReadingProgressController } from './reading-progress.controller';
import { ReadingProgressService } from './reading-progress.service';
@Module({
imports: [
@@ -52,7 +54,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
AchievementModule,
PositionConverterModule,
],
controllers: [BookController, ReadingAttemptController],
controllers: [BookController, ReadingAttemptController, ReadingProgressController],
providers: [
BookService,
BookRepository,
@@ -66,6 +68,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
DeliveryArtifactRepository,
DeliverySelectionService,
DeliveryHashRegistrationService,
ReadingProgressService,
],
exports: [BookService, BookReadService, BookQueryBuilder, AudiolessEpubService, DeliveryArtifactService, DeliverySelectionService],
})
@@ -1700,6 +1700,9 @@ export class BookRepository {
koboLocationValue: readingProgress.koboLocationValue,
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
koreaderProgress: readingProgress.koreaderProgress,
narrationPercentage: readingProgress.narrationPercentage,
narrationUpdatedAt: readingProgress.narrationUpdatedAt,
textUpdatedAt: readingProgress.textUpdatedAt,
updatedAt: readingProgress.updatedAt,
lastReadAt: readingProgress.lastReadAt,
})
@@ -0,0 +1,59 @@
import 'reflect-metadata';
import { READING_PROGRESS_MAX_WRITES } from '@bookorbit/types';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { ReadingProgressQueryDto, ReadingProgressWritesDto } from './reading-progress.dto';
async function errorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
return validate(plainToInstance(cls, value), { whitelist: true, forbidNonWhitelisted: true });
}
const write = {
fileId: 64,
kind: 'text',
percentage: 7.99,
editedAt: '2026-10-22T09:30:00.123Z',
text: { cfi: 'epubcfi(/6/22!/4/108/1:0)', page: null },
narration: { seconds: 812.4, fragment: 'c9R.xhtml#s12', sectionIndex: 9 },
kobo: { source: 'OEBPS/c9R.xhtml', type: null, value: null, contentPercent: 50 },
koreader: null,
};
describe('Reading progress DTO validation', () => {
it('accepts a full write and a bare one', async () => {
expect(await errorsFor(ReadingProgressWritesDto, { writes: [write] })).toHaveLength(0);
expect(await errorsFor(ReadingProgressWritesDto, { writes: [{ fileId: 64, percentage: 0 }] })).toHaveLength(0);
});
it('rejects an empty batch, an oversized one, and a file named twice', async () => {
expect((await errorsFor(ReadingProgressWritesDto, { writes: [] })).length).toBeGreaterThan(0);
const tooMany = Array.from({ length: READING_PROGRESS_MAX_WRITES + 1 }, (_, i) => ({ fileId: i + 1, percentage: 1 }));
expect((await errorsFor(ReadingProgressWritesDto, { writes: tooMany })).length).toBeGreaterThan(0);
expect((await errorsFor(ReadingProgressWritesDto, { writes: [write, { ...write, percentage: 9 }] })).length).toBeGreaterThan(0);
});
it('rejects an unknown kind, an out-of-range percentage and a loose edit time', async () => {
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, kind: 'audio' }] })).length).toBeGreaterThan(0);
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, percentage: 101 }] })).length).toBeGreaterThan(0);
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, editedAt: 'yesterday' }] })).length).toBeGreaterThan(0);
});
it('rejects fields the grouped shape does not have, at either level', async () => {
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, cfi: 'epubcfi(/6/2)' }] })).length).toBeGreaterThan(0);
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, text: { pageNumber: 3 } }] })).length).toBeGreaterThan(0);
});
it('holds each string to its column', async () => {
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, text: { cfi: 'x'.repeat(2001) } }] })).length).toBeGreaterThan(0);
expect((await errorsFor(ReadingProgressWritesDto, { writes: [{ ...write, kobo: { type: 'x'.repeat(65) } }] })).length).toBeGreaterThan(0);
});
it('reads query ids as numbers', async () => {
const query = plainToInstance(ReadingProgressQueryDto, { fileId: '64' });
expect(await validate(query)).toHaveLength(0);
expect(query.fileId).toBe(64);
expect((await errorsFor(ReadingProgressQueryDto, { bookId: 'abc' })).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,136 @@
import { READING_POSITION_KINDS, READING_PROGRESS_MAX_WRITES, type ReadingPositionKind } from '@bookorbit/types';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsIn,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class ReadingProgressQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
fileId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
bookId?: number;
}
// Each string is held to its column, so an oversized one is a 400 rather than a database error.
export class ReadingTextWriteDto {
@ValidateIf((o: ReadingTextWriteDto) => o.cfi != null)
@IsString()
@MaxLength(2000)
cfi?: string | null;
@ValidateIf((o: ReadingTextWriteDto) => o.page != null)
@IsInt()
@Min(0)
@Max(2147483647)
page?: number | null;
}
export class ReadingNarrationWriteDto {
@ValidateIf((o: ReadingNarrationWriteDto) => o.seconds != null)
@IsNumber()
@Min(0)
seconds?: number | null;
@ValidateIf((o: ReadingNarrationWriteDto) => o.fragment != null)
@IsString()
@MaxLength(4096)
fragment?: string | null;
@ValidateIf((o: ReadingNarrationWriteDto) => o.sectionIndex != null)
@IsInt()
@Min(0)
@Max(2147483647)
sectionIndex?: number | null;
}
export class ReadingKoboWriteDto {
@ValidateIf((o: ReadingKoboWriteDto) => o.source != null)
@IsString()
@MaxLength(4096)
source?: string | null;
@ValidateIf((o: ReadingKoboWriteDto) => o.type != null)
@IsString()
@MaxLength(64)
type?: string | null;
@ValidateIf((o: ReadingKoboWriteDto) => o.value != null)
@IsString()
@MaxLength(255)
value?: string | null;
@ValidateIf((o: ReadingKoboWriteDto) => o.contentPercent != null)
@IsNumber()
@Min(0)
@Max(100)
contentPercent?: number | null;
}
export class ReadingProgressWriteDto {
@IsInt()
@Min(1)
fileId!: number;
@IsOptional()
@IsIn(READING_POSITION_KINDS)
kind?: ReadingPositionKind;
@IsNumber()
@Min(0)
@Max(100)
percentage!: number;
@IsOptional()
@IsISO8601({ strict: true })
editedAt?: string;
@IsOptional()
@ValidateNested()
@Type(() => ReadingTextWriteDto)
text?: ReadingTextWriteDto;
@IsOptional()
@ValidateNested()
@Type(() => ReadingNarrationWriteDto)
narration?: ReadingNarrationWriteDto;
@IsOptional()
@ValidateNested()
@Type(() => ReadingKoboWriteDto)
kobo?: ReadingKoboWriteDto;
@ValidateIf((o: ReadingProgressWriteDto) => o.koreader != null)
@IsString()
koreader?: string | null;
}
export class ReadingProgressWritesDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(READING_PROGRESS_MAX_WRITES)
@ArrayUnique((write: ReadingProgressWriteDto) => write.fileId)
@ValidateNested({ each: true })
@Type(() => ReadingProgressWriteDto)
writes!: ReadingProgressWriteDto[];
}
@@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ReadingProgressController } from './reading-progress.controller';
const user = { id: 7 } as RequestUser;
function makeController() {
const progress = {
forFile: vi.fn().mockResolvedValue({ items: [] }),
forBook: vi.fn().mockResolvedValue({ items: [] }),
save: vi.fn().mockResolvedValue({ results: [] }),
clear: vi.fn().mockResolvedValue(undefined),
};
return { controller: new ReadingProgressController(progress as never), progress };
}
describe('ReadingProgressController', () => {
it('reads one file or one book', async () => {
const { controller, progress } = makeController();
await controller.list({ fileId: 64 }, user);
await controller.list({ bookId: 60 }, user);
expect(progress.forFile).toHaveBeenCalledWith(64, user);
expect(progress.forBook).toHaveBeenCalledWith(60, user);
});
it('rejects a read that names both a file and a book, or neither', () => {
const { controller } = makeController();
expect(() => controller.list({ fileId: 64, bookId: 60 }, user)).toThrow(BadRequestException);
expect(() => controller.list({}, user)).toThrow(BadRequestException);
});
it('passes the writes and the caller through', async () => {
const { controller, progress } = makeController();
const writes = [{ fileId: 64, percentage: 8 }];
await controller.save({ writes }, user);
expect(progress.save).toHaveBeenCalledWith(writes, user);
});
it('clears one file for the caller', async () => {
const { controller, progress } = makeController();
await controller.clear(64, user);
expect(progress.clear).toHaveBeenCalledWith(64, user);
});
it('requires no permission, since every route touches only the caller position', () => {
for (const route of [
ReadingProgressController.prototype.list,
ReadingProgressController.prototype.save,
ReadingProgressController.prototype.clear,
]) {
expect(Reflect.getMetadata(PERMISSION_KEY, route)).toBeUndefined();
}
});
});
@@ -0,0 +1,28 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ReadingProgressQueryDto, ReadingProgressWritesDto } from './dto/reading-progress.dto';
import { ReadingProgressService } from './reading-progress.service';
@Controller('reading-progress')
export class ReadingProgressController {
constructor(private readonly progress: ReadingProgressService) {}
@Get()
list(@Query() query: ReadingProgressQueryDto, @CurrentUser() user: RequestUser) {
if (query.fileId != null && query.bookId == null) return this.progress.forFile(query.fileId, user);
if (query.bookId != null && query.fileId == null) return this.progress.forBook(query.bookId, user);
throw new BadRequestException('Pass exactly one of fileId or bookId');
}
@Put()
save(@Body() dto: ReadingProgressWritesDto, @CurrentUser() user: RequestUser) {
return this.progress.save(dto.writes, user);
}
@Delete(':fileId')
@HttpCode(HttpStatus.NO_CONTENT)
async clear(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser) {
await this.progress.clear(fileId, user);
}
}
@@ -0,0 +1,200 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { RequestUser } from '../../common/types/request-user';
import { ReadingProgressService, toReadingProgressEntry } from './reading-progress.service';
const user = { id: 7 } as RequestUser;
function storedRow(overrides: Record<string, unknown> = {}) {
return {
percentage: 42.5,
cfi: 'epubcfi(/6/22!/4/108/1:0)',
pageNumber: null,
positionSeconds: 812.4,
mediaOverlayFragment: 'c9R.xhtml#s12',
mediaOverlaySectionIndex: 9,
narrationPercentage: 40,
narrationUpdatedAt: new Date('2026-10-20T10:00:00.000Z'),
textUpdatedAt: new Date('2026-10-21T10:00:00.000Z'),
koboLocationSource: 'OEBPS/c9R.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.12.3',
koboContentSourceProgressPercent: 50,
koreaderProgress: '/body/DocFragment[10]/body/p[3]',
updatedAt: new Date('2026-10-21T10:00:00.000Z'),
...overrides,
};
}
function makeService() {
const books = {
getProgress: vi.fn().mockResolvedValue(storedRow()),
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
verifyFileAccess: vi.fn().mockResolvedValue({ id: 64 }),
saveProgress: vi.fn().mockResolvedValue(undefined),
clearFileProgress: vi.fn().mockResolvedValue(undefined),
};
const repo = {
findProgress: vi.fn().mockResolvedValue(storedRow()),
findProgressByBook: vi.fn().mockResolvedValue([]),
};
return { service: new ReadingProgressService(books as never, repo as never), books, repo };
}
describe('toReadingProgressEntry', () => {
it('groups a stored row by the position each field belongs to', () => {
expect(toReadingProgressEntry(64, storedRow())).toEqual({
fileId: 64,
percentage: 42.5,
updatedAt: '2026-10-21T10:00:00.000Z',
text: { cfi: 'epubcfi(/6/22!/4/108/1:0)', page: null, updatedAt: '2026-10-21T10:00:00.000Z' },
narration: { seconds: 812.4, fragment: 'c9R.xhtml#s12', sectionIndex: 9, percentage: 40, updatedAt: '2026-10-20T10:00:00.000Z' },
kobo: { source: 'OEBPS/c9R.xhtml', type: 'KoboSpan', value: 'kobo.12.3', contentPercent: 50 },
koreader: '/body/DocFragment[10]/body/p[3]',
});
});
it('answers an empty position for a file nothing was saved for', () => {
const entry = toReadingProgressEntry(64, null);
expect(entry.percentage).toBe(0);
expect(entry.updatedAt).toBeNull();
expect(entry.text).toEqual({ cfi: null, page: null, updatedAt: null });
expect(entry.narration.fragment).toBeNull();
expect(entry.kobo.value).toBeNull();
expect(entry.koreader).toBeNull();
});
});
describe('ReadingProgressService', () => {
it('reads one file through the access-checked lookup', async () => {
const { service, books } = makeService();
const result = await service.forFile(64, user);
expect(books.getProgress).toHaveBeenCalledWith(7, 64, user);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.fileId).toBe(64);
});
it('checks book access before listing every file of the book', async () => {
const { service, books, repo } = makeService();
repo.findProgressByBook.mockResolvedValue([
{ fileId: 64, ...storedRow() },
{ fileId: 65, ...storedRow({ percentage: null, cfi: null, updatedAt: null, textUpdatedAt: null }) },
]);
const result = await service.forBook(60, user);
expect(books.verifyBookAccess).toHaveBeenCalledWith(60, user);
expect(repo.findProgressByBook).toHaveBeenCalledWith(7, 60);
expect(result.items.map((item) => [item.fileId, item.percentage])).toEqual([
[64, 42.5],
[65, 0],
]);
});
it('saves a write through the existing progress pipeline with its fields mapped back', async () => {
const { service, books } = makeService();
const result = await service.save(
[
{
fileId: 64,
kind: 'narration',
percentage: 8,
text: { cfi: 'epubcfi(/6/2)', page: null },
narration: { seconds: 12, fragment: 'a.xhtml#s1', sectionIndex: 2 },
kobo: { source: 'a.xhtml', type: 'KoboSpan', value: 'kobo.1.1', contentPercent: 10 },
koreader: '/body/p[1]',
},
],
user,
);
expect(books.saveProgress).toHaveBeenCalledWith(
7,
64,
{
source: 'narration',
percentage: 8,
cfi: 'epubcfi(/6/2)',
pageNumber: null,
positionSeconds: 12,
mediaOverlayFragment: 'a.xhtml#s1',
mediaOverlaySectionIndex: 2,
koboLocationSource: 'a.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.1.1',
koboContentSourceProgressPercent: 10,
koreaderProgress: '/body/p[1]',
},
user,
);
expect(result.results).toEqual([expect.objectContaining({ fileId: 64, status: 'saved' })]);
});
// The old route kept the stored narration marker when the fields were left out and cleared it
// only on an explicit null. The grouped write has to keep both meanings apart.
it('keeps the narration marker when the write leaves it out, and clears it on an explicit null', async () => {
const { service, books } = makeService();
await service.save([{ fileId: 64, percentage: 8, text: { cfi: 'epubcfi(/6/2)' } }], user);
const omitted = books.saveProgress.mock.calls[0]![2];
expect(omitted.positionSeconds).toBeUndefined();
expect(omitted.mediaOverlayFragment).toBeUndefined();
expect(omitted.mediaOverlaySectionIndex).toBeUndefined();
await service.save([{ fileId: 64, percentage: 8, narration: { seconds: null, fragment: null, sectionIndex: null } }], user);
const cleared = books.saveProgress.mock.calls[1]![2];
expect(cleared.positionSeconds).toBeNull();
expect(cleared.mediaOverlayFragment).toBeNull();
expect(cleared.mediaOverlaySectionIndex).toBeNull();
});
it('returns the stored position instead of saving a write made before it', async () => {
const { service, books } = makeService();
const result = await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T09:59:59.000Z' }], user);
expect(books.saveProgress).not.toHaveBeenCalled();
expect(result.results[0]).toEqual({ fileId: 64, status: 'superseded', progress: toReadingProgressEntry(64, storedRow()) });
});
it('saves a write made at or after the stored position', async () => {
const { service, books } = makeService();
await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T10:00:00.000Z' }], user);
await service.save([{ fileId: 64, percentage: 9, editedAt: '2026-10-21T10:00:01.000Z' }], user);
expect(books.saveProgress).toHaveBeenCalledTimes(2);
});
it('saves a write with an edit time when nothing was stored yet', async () => {
const { service, books, repo } = makeService();
repo.findProgress.mockResolvedValueOnce(null);
await service.save([{ fileId: 64, percentage: 8, editedAt: '2026-10-21T09:00:00.000Z' }], user);
expect(books.saveProgress).toHaveBeenCalledTimes(1);
});
it('writes nothing when any file in the batch is out of reach', async () => {
const { service, books } = makeService();
books.verifyFileAccess.mockResolvedValueOnce({ id: 64 }).mockRejectedValueOnce(new NotFoundException());
await expect(
service.save(
[
{ fileId: 64, percentage: 8 },
{ fileId: 65, percentage: 9 },
],
user,
),
).rejects.toBeInstanceOf(NotFoundException);
expect(books.saveProgress).not.toHaveBeenCalled();
});
it('answers results in the order the writes were sent', async () => {
const { service } = makeService();
const result = await service.save(
[
{ fileId: 65, percentage: 8 },
{ fileId: 64, percentage: 9 },
],
user,
);
expect(result.results.map((entry) => entry.fileId)).toEqual([65, 64]);
});
it('clears one file through the existing reset', async () => {
const { service, books } = makeService();
await service.clear(64, user);
expect(books.clearFileProgress).toHaveBeenCalledWith(7, 64, user);
});
});
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { ReadingProgressEntry, ReadingProgressListResponse, ReadingProgressWriteResponse, ReadingProgressWriteResult } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookRepository } from './book.repository';
import { BookService } from './book.service';
import type { ReadingProgressWriteDto } from './dto/reading-progress.dto';
import type { SaveProgressDto } from './dto/save-progress.dto';
/** A stored progress row, or the left-joined half of one for a file nothing was saved for. */
interface ProgressRow {
percentage: number | null;
cfi: string | null;
pageNumber: number | null;
positionSeconds: number | null;
mediaOverlayFragment: string | null;
mediaOverlaySectionIndex: number | null;
narrationPercentage: number | null;
narrationUpdatedAt: Date | null;
textUpdatedAt: Date | null;
koboLocationSource: string | null;
koboLocationType: string | null;
koboLocationValue: string | null;
koboContentSourceProgressPercent: number | null;
koreaderProgress: string | null;
updatedAt: Date | null;
}
function iso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
export function toReadingProgressEntry(fileId: number, row: ProgressRow | null): ReadingProgressEntry {
return {
fileId,
percentage: row?.percentage ?? 0,
updatedAt: iso(row?.updatedAt),
text: { cfi: row?.cfi ?? null, page: row?.pageNumber ?? null, updatedAt: iso(row?.textUpdatedAt) },
narration: {
seconds: row?.positionSeconds ?? null,
fragment: row?.mediaOverlayFragment ?? null,
sectionIndex: row?.mediaOverlaySectionIndex ?? null,
percentage: row?.narrationPercentage ?? null,
updatedAt: iso(row?.narrationUpdatedAt),
},
kobo: {
source: row?.koboLocationSource ?? null,
type: row?.koboLocationType ?? null,
value: row?.koboLocationValue ?? null,
contentPercent: row?.koboContentSourceProgressPercent ?? null,
},
koreader: row?.koreaderProgress ?? null,
};
}
/** Narration fields stay undefined when the write leaves them out, which keeps the stored marker. */
function toSaveProgressDto(write: ReadingProgressWriteDto): SaveProgressDto {
return {
source: write.kind,
percentage: write.percentage,
cfi: write.text?.cfi,
pageNumber: write.text?.page,
positionSeconds: write.narration?.seconds,
mediaOverlayFragment: write.narration?.fragment,
mediaOverlaySectionIndex: write.narration?.sectionIndex,
koboLocationSource: write.kobo?.source,
koboLocationType: write.kobo?.type,
koboLocationValue: write.kobo?.value,
koboContentSourceProgressPercent: write.kobo?.contentPercent,
koreaderProgress: write.koreader,
};
}
@Injectable()
export class ReadingProgressService {
constructor(
private readonly books: BookService,
private readonly bookRepo: BookRepository,
) {}
async forFile(fileId: number, user: RequestUser): Promise<ReadingProgressListResponse> {
const row = await this.books.getProgress(user.id, fileId, user);
return { items: [toReadingProgressEntry(fileId, row)] };
}
async forBook(bookId: number, user: RequestUser): Promise<ReadingProgressListResponse> {
await this.books.verifyBookAccess(bookId, user);
const rows = await this.bookRepo.findProgressByBook(user.id, bookId);
return { items: rows.map((row) => toReadingProgressEntry(row.fileId, row)) };
}
/**
* Every file is checked before any is written, so a batch naming one the caller cannot open
* changes nothing. Saving a position twice is harmless, so a batch that fails partway can be sent
* again whole.
*/
async save(writes: ReadingProgressWriteDto[], user: RequestUser): Promise<ReadingProgressWriteResponse> {
for (const write of writes) await this.books.verifyFileAccess(write.fileId, user);
const results: ReadingProgressWriteResult[] = [];
for (const write of writes) results.push(await this.saveOne(write, user));
return { results };
}
async clear(fileId: number, user: RequestUser): Promise<void> {
await this.books.clearFileProgress(user.id, fileId, user);
}
private async saveOne(write: ReadingProgressWriteDto, user: RequestUser): Promise<ReadingProgressWriteResult> {
if (write.editedAt) {
const stored = await this.bookRepo.findProgress(user.id, write.fileId);
if (stored?.updatedAt && stored.updatedAt.getTime() > Date.parse(write.editedAt)) {
return { fileId: write.fileId, status: 'superseded', progress: toReadingProgressEntry(write.fileId, stored) };
}
}
await this.books.saveProgress(user.id, write.fileId, toSaveProgressDto(write), user);
const saved = await this.bookRepo.findProgress(user.id, write.fileId);
return { fileId: write.fileId, status: 'saved', progress: toReadingProgressEntry(write.fileId, saved) };
}
}
@@ -1,7 +1,7 @@
{
"totalRoutes": 701,
"totalRoutes": 704,
"byPermission": {
"(none)": 294,
"(none)": 297,
"Permission.ManageMetadataConfig": 41,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 54,
@@ -1210,5 +1210,41 @@
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book/reading-progress.controller.ts",
"className": "ReadingProgressController",
"methodName": "list",
"httpMethod": "GET",
"path": "/reading-progress",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book/reading-progress.controller.ts",
"className": "ReadingProgressController",
"methodName": "save",
"httpMethod": "PUT",
"path": "/reading-progress",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book/reading-progress.controller.ts",
"className": "ReadingProgressController",
"methodName": "clear",
"httpMethod": "DELETE",
"path": "/reading-progress/:fileId",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
}
]