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' })
}