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