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
|
} 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', () => {
|
describe('DetailsTab - missing state', () => {
|
||||||
it('renders the amber warning banner', () => {
|
it('renders the amber warning banner', () => {
|
||||||
const wrapper = mount(DetailsTab, {
|
const wrapper = mount(DetailsTab, {
|
||||||
@@ -482,8 +514,8 @@ describe('DetailsTab - present state', () => {
|
|||||||
|
|
||||||
it('formats tiny non-zero progress as <1%', async () => {
|
it('formats tiny non-zero progress as <1%', async () => {
|
||||||
vi.mocked(api).mockImplementation(async (input) => {
|
vi.mocked(api).mockImplementation(async (input) => {
|
||||||
if (input === '/api/v1/books/1/progress') {
|
if (input === '/api/v1/reading-progress?bookId=1') {
|
||||||
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }])
|
return makeApiResponse(progressList([{ fileId: 101, cfi: null, pageNumber: null, percentage: 0.4, updatedAt: null }]))
|
||||||
}
|
}
|
||||||
if (input === '/api/v1/collections/membership') {
|
if (input === '/api/v1/collections/membership') {
|
||||||
return makeApiResponse([])
|
return makeApiResponse([])
|
||||||
@@ -518,8 +550,8 @@ describe('DetailsTab - present state', () => {
|
|||||||
|
|
||||||
it('formats near-complete progress as >99%', async () => {
|
it('formats near-complete progress as >99%', async () => {
|
||||||
vi.mocked(api).mockImplementation(async (input) => {
|
vi.mocked(api).mockImplementation(async (input) => {
|
||||||
if (input === '/api/v1/books/1/progress') {
|
if (input === '/api/v1/reading-progress?bookId=1') {
|
||||||
return makeApiResponse([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }])
|
return makeApiResponse(progressList([{ fileId: 101, cfi: null, pageNumber: null, percentage: 99.6, updatedAt: null }]))
|
||||||
}
|
}
|
||||||
if (input === '/api/v1/collections/membership') {
|
if (input === '/api/v1/collections/membership') {
|
||||||
return makeApiResponse([])
|
return makeApiResponse([])
|
||||||
@@ -554,19 +586,21 @@ describe('DetailsTab - present state', () => {
|
|||||||
|
|
||||||
it('shows EPUB3 narration progress when only media-overlay position is stored', async () => {
|
it('shows EPUB3 narration progress when only media-overlay position is stored', async () => {
|
||||||
vi.mocked(api).mockImplementation(async (input) => {
|
vi.mocked(api).mockImplementation(async (input) => {
|
||||||
if (input === '/api/v1/books/1/progress') {
|
if (input === '/api/v1/reading-progress?bookId=1') {
|
||||||
return makeApiResponse([
|
return makeApiResponse(
|
||||||
{
|
progressList([
|
||||||
fileId: 101,
|
{
|
||||||
cfi: null,
|
fileId: 101,
|
||||||
pageNumber: null,
|
cfi: null,
|
||||||
percentage: 0,
|
pageNumber: null,
|
||||||
positionSeconds: 900,
|
percentage: 0,
|
||||||
mediaOverlayFragment: 'OPS/chapter.xhtml#s12',
|
positionSeconds: 900,
|
||||||
mediaOverlaySectionIndex: 3,
|
mediaOverlayFragment: 'OPS/chapter.xhtml#s12',
|
||||||
updatedAt: null,
|
mediaOverlaySectionIndex: 3,
|
||||||
},
|
updatedAt: null,
|
||||||
])
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (input === '/api/v1/collections?bookIds=1') {
|
if (input === '/api/v1/collections?bookIds=1') {
|
||||||
return makeApiResponse([])
|
return makeApiResponse([])
|
||||||
@@ -606,12 +640,12 @@ describe('DetailsTab - present state', () => {
|
|||||||
{ fileId: 101, cfi: null, pageNumber: null, percentage: 22, updatedAt: null },
|
{ fileId: 101, cfi: null, pageNumber: null, percentage: 22, updatedAt: null },
|
||||||
]
|
]
|
||||||
vi.mocked(api).mockImplementation(async (input, init) => {
|
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 = []
|
progressRows = []
|
||||||
return makeApiResponse({})
|
return makeApiResponse({})
|
||||||
}
|
}
|
||||||
if (input === '/api/v1/books/1/progress') {
|
if (input === '/api/v1/reading-progress?bookId=1') {
|
||||||
return makeApiResponse(progressRows)
|
return makeApiResponse(progressList(progressRows))
|
||||||
}
|
}
|
||||||
if (input === '/api/v1/collections/membership') {
|
if (input === '/api/v1/collections/membership') {
|
||||||
return makeApiResponse([])
|
return makeApiResponse([])
|
||||||
@@ -649,7 +683,7 @@ describe('DetailsTab - present state', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(confirmSpy).toHaveBeenCalled()
|
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()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { BookDetail, BookReadingSessionStats, ReadStatus, UserBookStatus }
|
|||||||
import { isAudioFormat } from '@bookorbit/types'
|
import { isAudioFormat } from '@bookorbit/types'
|
||||||
import { bookFormatEntries, formatKeyName } from '@/features/book/lib/book-formats'
|
import { bookFormatEntries, formatKeyName } from '@/features/book/lib/book-formats'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { fetchBookProgress } from '@/lib/reading-progress'
|
||||||
import { formatDate, formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
|
import { formatDate, formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
import { STATUS_COLORS, STATUS_ICONS, STATUS_OPTIONS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
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 bookId = props.book.id
|
||||||
const hasAudio = props.book.files.some((file) => file.format != null && isAudioFormat(file.format))
|
const hasAudio = props.book.files.some((file) => file.format != null && isAudioFormat(file.format))
|
||||||
try {
|
try {
|
||||||
const [progressRes, audioRes] = await Promise.all([
|
const [progressRows, audioRes] = await Promise.all([
|
||||||
api(`/api/v1/books/${bookId}/progress`).catch(() => null),
|
fetchBookProgress(bookId).catch(() => null),
|
||||||
hasAudio ? api(`/api/v1/audiobooks/${bookId}/playback-state`).catch(() => null) : Promise.resolve(null),
|
hasAudio ? api(`/api/v1/audiobooks/${bookId}/playback-state`).catch(() => null) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
if (bookId !== props.book.id) return
|
if (bookId !== props.book.id) return
|
||||||
|
|
||||||
let max = 0
|
let max = 0
|
||||||
if (progressRes?.ok) {
|
for (const row of progressRows ?? []) {
|
||||||
const rows = (await progressRes.json()) as { fileId: number; percentage: number }[]
|
if (Number.isFinite(row.percentage)) max = Math.max(max, row.percentage)
|
||||||
for (const row of rows) {
|
|
||||||
if (Number.isFinite(row.percentage)) max = Math.max(max, row.percentage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (audioRes?.ok) {
|
if (audioRes?.ok) {
|
||||||
const data = (await audioRes.json()) as { percentage?: number } | null
|
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { clearReadingProgress, fetchBookProgress } from '@/lib/reading-progress'
|
||||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||||
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
|
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
|
||||||
@@ -1135,9 +1136,7 @@ async function handleResetFileProgress(row: EditionProgress) {
|
|||||||
setFileResetting(fileId, true)
|
setFileResetting(fileId, true)
|
||||||
try {
|
try {
|
||||||
const res =
|
const res =
|
||||||
fileId < 0
|
fileId < 0 ? await api(`/api/v1/audiobooks/${props.book.id}/playback-state`, { method: 'DELETE' }) : await clearReadingProgress(fileId)
|
||||||
? await api(`/api/v1/audiobooks/${props.book.id}/playback-state`, { method: 'DELETE' })
|
|
||||||
: await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
|
|
||||||
if (!res.ok) throw new Error('Failed to reset file progress')
|
if (!res.ok) throw new Error('Failed to reset file progress')
|
||||||
await loadSupplemental()
|
await loadSupplemental()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1152,7 +1151,7 @@ async function loadSupplemental() {
|
|||||||
supplementalLoading.value = true
|
supplementalLoading.value = true
|
||||||
const hasAudio = props.book.files.some((f) => f.format && FORMAT_TO_GROUP[f.format] === 'audio')
|
const hasAudio = props.book.files.some((f) => f.format && FORMAT_TO_GROUP[f.format] === 'audio')
|
||||||
try {
|
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 audioProgressPromise = hasAudio ? api(`/api/v1/audiobooks/${props.book.id}/playback-state`).catch(() => null) : Promise.resolve(null)
|
||||||
const collectionsPromise = api('/api/v1/collections/membership', {
|
const collectionsPromise = api('/api/v1/collections/membership', {
|
||||||
method: 'POST',
|
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 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 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,
|
progressPromise,
|
||||||
audioProgressPromise,
|
audioProgressPromise,
|
||||||
collectionsPromise,
|
collectionsPromise,
|
||||||
@@ -1172,7 +1171,7 @@ async function loadSupplemental() {
|
|||||||
|
|
||||||
if (requestId !== supplementalRequestId) return
|
if (requestId !== supplementalRequestId) return
|
||||||
|
|
||||||
const progressRows: FileProgressRow[] = progressRes && progressRes.ok ? ((await progressRes.json()) as FileProgressRow[]) : []
|
const progressRows: FileProgressRow[] = fetchedProgressRows ?? []
|
||||||
const progressMap: Record<number, FileProgress> = {}
|
const progressMap: Record<number, FileProgress> = {}
|
||||||
for (const row of progressRows) {
|
for (const row of progressRows) {
|
||||||
if (!Number.isFinite(row.fileId)) continue
|
if (!Number.isFinite(row.fileId)) continue
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url.includes('/koreader/books/')) return response(null)
|
if (url.includes('/koreader/books/')) return response(null)
|
||||||
|
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
|
||||||
if (url.includes('/progress')) return response([])
|
if (url.includes('/progress')) return response([])
|
||||||
return response({})
|
return response({})
|
||||||
})
|
})
|
||||||
@@ -531,6 +532,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url.includes('/koreader/books/')) return response(null)
|
if (url.includes('/koreader/books/')) return response(null)
|
||||||
|
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
|
||||||
if (url.includes('/progress')) return response([])
|
if (url.includes('/progress')) return response([])
|
||||||
return response({})
|
return response({})
|
||||||
})
|
})
|
||||||
@@ -697,6 +699,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
||||||
}
|
}
|
||||||
if (url.includes('/koreader/books/')) return response(null)
|
if (url.includes('/koreader/books/')) return response(null)
|
||||||
|
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
|
||||||
if (url.includes('/progress')) return response([])
|
if (url.includes('/progress')) return response([])
|
||||||
return response({})
|
return response({})
|
||||||
})
|
})
|
||||||
@@ -705,7 +708,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(mocks.api).toHaveBeenNthCalledWith(1, '/api/v1/books/12/reset-reading-state', { method: 'POST' })
|
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/books/12/kobo-state')
|
||||||
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/12/progress')
|
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/12/progress')
|
||||||
expect(wrapper.emitted('saved')).toEqual([
|
expect(wrapper.emitted('saved')).toEqual([
|
||||||
@@ -795,6 +798,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
if (url.includes('/collections/membership')) return response([])
|
if (url.includes('/collections/membership')) return response([])
|
||||||
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
||||||
if (url.includes('/koreader/books/')) return response(null)
|
if (url.includes('/koreader/books/')) return response(null)
|
||||||
|
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
|
||||||
if (url.includes('/progress')) return response([])
|
if (url.includes('/progress')) return response([])
|
||||||
return response({})
|
return response({})
|
||||||
})
|
})
|
||||||
@@ -897,6 +901,7 @@ describe('DetailsTab cover surface', () => {
|
|||||||
if (url.includes('/collections/membership')) return response([])
|
if (url.includes('/collections/membership')) return response([])
|
||||||
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
|
||||||
if (url.includes('/koreader/books/')) return response(null)
|
if (url.includes('/koreader/books/')) return response(null)
|
||||||
|
if (url.startsWith('/api/v1/reading-progress')) return response({ items: [] })
|
||||||
if (url.includes('/progress')) return response([])
|
if (url.includes('/progress')) return response([])
|
||||||
return response({})
|
return response({})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ function routeApi(
|
|||||||
if (url.includes('/sessions')) {
|
if (url.includes('/sessions')) {
|
||||||
return Promise.resolve(jsonResponse({ items: sessions, total: sessions.length, page: 1, pageSize: 25, stats: makeStats(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([]))
|
if (url.includes('/progress')) return Promise.resolve(jsonResponse([]))
|
||||||
return Promise.resolve(jsonResponse({}))
|
return Promise.resolve(jsonResponse({}))
|
||||||
}
|
}
|
||||||
@@ -508,7 +509,8 @@ describe('ReadingLogTab', () => {
|
|||||||
held = []
|
held = []
|
||||||
return Promise.resolve({ ok: true, status: 204, json: async () => ({}) } as Response)
|
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)
|
return routeApi({ sessions: [makeSession({ source: 'koreader' })], koreader: () => makeKoreaderProgress({ heldByReset: held }) })(input)
|
||||||
})
|
})
|
||||||
const wrapper = mountTab()
|
const wrapper = mountTab()
|
||||||
@@ -523,7 +525,7 @@ describe('ReadingLogTab', () => {
|
|||||||
expect(releaseCall?.[1]).toMatchObject({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-1' }) })
|
expect(releaseCall?.[1]).toMatchObject({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-1' }) })
|
||||||
|
|
||||||
const urls = mocks.api.mock.calls.map((call) => String(call[0]))
|
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.startsWith('/api/v1/books/10/sessions?'))).toBe(true)
|
||||||
expect(urls.some((url) => url.includes('/api/v1/books/10/reading-attempts'))).toBe(true)
|
expect(urls.some((url) => url.includes('/api/v1/books/10/reading-attempts'))).toBe(true)
|
||||||
expect(wrapper.emitted('refetch')).toHaveLength(1)
|
expect(wrapper.emitted('refetch')).toHaveLength(1)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
type BookDetailFile,
|
type BookDetailFile,
|
||||||
} from '@bookorbit/types'
|
} from '@bookorbit/types'
|
||||||
import { bookFormatEntries, fileFormatKey } from '@/features/book/lib/book-formats'
|
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'
|
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 = {
|
export type FileProgress = {
|
||||||
percentage: number
|
percentage: number
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
@@ -205,10 +205,8 @@ export function useBookFileTree(book: Ref<BookDetail>) {
|
|||||||
async function loadProgress() {
|
async function loadProgress() {
|
||||||
const requestId = ++progressRequestId
|
const requestId = ++progressRequestId
|
||||||
try {
|
try {
|
||||||
const response = await api(`/api/v1/books/${book.value.id}/progress`)
|
const rows = await fetchBookProgress(book.value.id)
|
||||||
if (!response.ok) return
|
if (!rows || requestId !== progressRequestId) return
|
||||||
const rows = (await response.json()) as { fileId: number; percentage: number; updatedAt: string | null }[]
|
|
||||||
if (requestId !== progressRequestId) return
|
|
||||||
const next: Record<number, FileProgress> = {}
|
const next: Record<number, FileProgress> = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!Number.isFinite(row.fileId)) continue
|
if (!Number.isFinite(row.fileId)) continue
|
||||||
|
|||||||
@@ -197,20 +197,17 @@ describe('useReaderProgress', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(2500)
|
await vi.advanceTimersByTimeAsync(2500)
|
||||||
|
|
||||||
const saveCalls = apiMock.mock.calls.filter(
|
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)
|
expect(saveCalls.length).toBe(1)
|
||||||
const saveCall = saveCalls[0]
|
const saveCall = saveCalls[0]
|
||||||
if (!saveCall) throw new Error('Expected one progress save call')
|
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(saveBody).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
percentage: 30,
|
percentage: 30,
|
||||||
koboLocationSource: 'OEBPS/html/ch3.xhtml',
|
kobo: { source: 'OEBPS/html/ch3.xhtml', type: 'KoboSpan', value: 'kobo.25.1', contentPercent: 25 },
|
||||||
koboLocationType: 'KoboSpan',
|
koreader: '/body/DocFragment[3]/body/p[4]/text()[1].2',
|
||||||
koboLocationValue: 'kobo.25.1',
|
|
||||||
koboContentSourceProgressPercent: 25,
|
|
||||||
koreaderProgress: '/body/DocFragment[3]/body/p[4]/text()[1].2',
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,20 +235,17 @@ describe('useReaderProgress', () => {
|
|||||||
await progress.save()
|
await progress.save()
|
||||||
|
|
||||||
const saveCall = apiMock.mock.calls.find(
|
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')
|
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(saveBody).toEqual(
|
||||||
expect.objectContaining({
|
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,
|
percentage: 69.604585363819,
|
||||||
koboLocationSource: null,
|
kobo: { source: null, type: null, value: null, contentPercent: 87.5 },
|
||||||
koboLocationType: null,
|
koreader: '/body/DocFragment[19]/body/p[222]/font/text().604',
|
||||||
koboLocationValue: null,
|
|
||||||
koboContentSourceProgressPercent: 87.5,
|
|
||||||
koreaderProgress: '/body/DocFragment[19]/body/p[222]/font/text().604',
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,17 +8,71 @@ import { formatTimeRemaining, useReaderProgress, type NewerPosition } from './us
|
|||||||
|
|
||||||
const mockApi = vi.mocked(api)
|
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>) {
|
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() {
|
function makeErrorResponse() {
|
||||||
return { ok: false } as Response
|
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> {
|
function postedProgressBody(callIndex = 0): Record<string, unknown> {
|
||||||
const [, opts] = mockApi.mock.calls[callIndex]!
|
const write = sentWrite(callIndex)
|
||||||
return JSON.parse((opts as RequestInit).body as string) as Record<string, unknown>
|
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', () => {
|
describe('formatTimeRemaining', () => {
|
||||||
@@ -95,7 +149,7 @@ describe('useReaderProgress', () => {
|
|||||||
|
|
||||||
await load()
|
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 () => {
|
it('sets state from response data on ok response', async () => {
|
||||||
@@ -260,7 +314,7 @@ describe('useReaderProgress', () => {
|
|||||||
|
|
||||||
describe('positions it did not move to (#1136)', () => {
|
describe('positions it did not move to (#1136)', () => {
|
||||||
function postCount() {
|
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>) {
|
async function openWith(saved: Record<string, unknown>) {
|
||||||
@@ -392,7 +446,7 @@ describe('useReaderProgress', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(2500)
|
await vi.advanceTimersByTimeAsync(2500)
|
||||||
|
|
||||||
expect(progress.mediaOverlayFragment.value).toBe('c.xhtml#s40')
|
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 () => {
|
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)
|
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(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)
|
mockApi.mockResolvedValueOnce(elsewhere)
|
||||||
expect(await progress.checkForNewerPosition()).toBeNull()
|
expect(await progress.checkForNewerPosition()).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -425,7 +482,7 @@ describe('useReaderProgress', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function posts() {
|
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>()) {
|
async function narrating(movedBack = vi.fn<(position: NewerPosition) => void>()) {
|
||||||
@@ -499,7 +556,10 @@ describe('useReaderProgress', () => {
|
|||||||
await progress.keepCurrentPosition(newer, 'narration')
|
await progress.keepCurrentPosition(newer, 'narration')
|
||||||
|
|
||||||
expect(posts()).toHaveLength(1)
|
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) {
|
async function savedAt(progress: ReturnType<typeof useReaderProgress>, fragment: string, date: string) {
|
||||||
@@ -706,7 +766,7 @@ describe('useReaderProgress', () => {
|
|||||||
|
|
||||||
expect(mockApi).not.toHaveBeenCalled()
|
expect(mockApi).not.toHaveBeenCalled()
|
||||||
vi.advanceTimersByTime(2000)
|
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', () => {
|
it('does not schedule save when trackingEnabled is false', () => {
|
||||||
@@ -739,7 +799,7 @@ describe('useReaderProgress', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('save()', () => {
|
describe('save()', () => {
|
||||||
it('calls api POST with all progress fields', async () => {
|
it('puts the write to the progress route', async () => {
|
||||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||||
const elapsedMinutes = ref(0)
|
const elapsedMinutes = ref(0)
|
||||||
const { save } = useReaderProgress(1, 42, elapsedMinutes)
|
const { save } = useReaderProgress(1, 42, elapsedMinutes)
|
||||||
@@ -747,15 +807,42 @@ describe('useReaderProgress', () => {
|
|||||||
await save()
|
await save()
|
||||||
|
|
||||||
expect(mockApi).toHaveBeenCalledWith(
|
expect(mockApi).toHaveBeenCalledWith(
|
||||||
'/api/v1/books/files/42/progress',
|
'/api/v1/reading-progress',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 () => {
|
it('does not call api when trackingEnabled is false', async () => {
|
||||||
const elapsedMinutes = ref(0)
|
const elapsedMinutes = ref(0)
|
||||||
const { save } = useReaderProgress(1, 42, elapsedMinutes, 0, {
|
const { save } = useReaderProgress(1, 42, elapsedMinutes, 0, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, onUnmounted, ref, unref, type MaybeRef, type Ref } from 'vue'
|
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'
|
import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate'
|
||||||
|
|
||||||
export type FooterDisplayMode = 0 | 1 | 2
|
export type FooterDisplayMode = 0 | 1 | 2
|
||||||
@@ -143,9 +143,8 @@ export function useReaderProgress(
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!unref(trackingEnabled)) return
|
if (!unref(trackingEnabled)) return
|
||||||
const res = await api(`/api/v1/books/files/${fileId}/progress`)
|
const data = await fetchFileProgress(fileId)
|
||||||
if (!res.ok) return
|
if (!data) return
|
||||||
const data = await res.json()
|
|
||||||
hadSavedPosition =
|
hadSavedPosition =
|
||||||
normalizeString(data.cfi) !== null ||
|
normalizeString(data.cfi) !== null ||
|
||||||
normalizeString(data.koboLocationValue) !== null ||
|
normalizeString(data.koboLocationValue) !== null ||
|
||||||
@@ -254,9 +253,8 @@ export function useReaderProgress(
|
|||||||
const secondsOf = options.narrationSeconds
|
const secondsOf = options.narrationSeconds
|
||||||
const fragment = mediaOverlayFragment.value
|
const fragment = mediaOverlayFragment.value
|
||||||
if (!secondsOf || !fragment) return null
|
if (!secondsOf || !fragment) return null
|
||||||
const res = await api(`/api/v1/books/files/${fileId}/progress`).catch(() => null)
|
const data = await fetchFileProgress(fileId).catch(() => null)
|
||||||
if (!res?.ok) return null
|
if (!data) return null
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
|
||||||
const stamp = positionStamp(data)
|
const stamp = positionStamp(data)
|
||||||
const serverFragment = normalizeString(data.mediaOverlayFragment)
|
const serverFragment = normalizeString(data.mediaOverlayFragment)
|
||||||
if (!serverFragment || serverFragment === fragment) return null
|
if (!serverFragment || serverFragment === fragment) return null
|
||||||
@@ -279,29 +277,29 @@ export function useReaderProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function postProgress(safePercentage: number) {
|
function postProgress(safePercentage: number) {
|
||||||
return api(`/api/v1/books/files/${fileId}/progress`, {
|
return saveReadingProgress({
|
||||||
method: 'POST',
|
fileId,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
kind: pendingSource.value,
|
||||||
body: JSON.stringify({
|
percentage: safePercentage,
|
||||||
cfi: cfi.value,
|
text: { cfi: cfi.value, page: normalizePageNumber(pageNumber.value) },
|
||||||
pageNumber: normalizePageNumber(pageNumber.value),
|
kobo: {
|
||||||
percentage: safePercentage,
|
source: koboLocationSource.value,
|
||||||
koboLocationSource: koboLocationSource.value,
|
type: koboLocationType.value,
|
||||||
koboLocationType: koboLocationType.value,
|
value: koboLocationValue.value,
|
||||||
koboLocationValue: koboLocationValue.value,
|
contentPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
|
||||||
koboContentSourceProgressPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
|
},
|
||||||
koreaderProgress: koreaderProgress.value,
|
koreader: koreaderProgress.value,
|
||||||
// Left out of a page turn, which says nothing about narration: sent along, the marker this
|
// 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.
|
// reader read when it opened went back over a newer one another device had saved since.
|
||||||
...(pendingSource.value === 'narration'
|
...(pendingSource.value === 'narration'
|
||||||
? {
|
? {
|
||||||
positionSeconds: positionSeconds.value,
|
narration: {
|
||||||
mediaOverlayFragment: mediaOverlayFragment.value,
|
seconds: positionSeconds.value,
|
||||||
mediaOverlaySectionIndex: mediaOverlaySectionIndex.value,
|
fragment: mediaOverlayFragment.value,
|
||||||
}
|
sectionIndex: mediaOverlaySectionIndex.value,
|
||||||
: {}),
|
},
|
||||||
source: pendingSource.value,
|
}
|
||||||
}),
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +310,8 @@ export function useReaderProgress(
|
|||||||
*/
|
*/
|
||||||
async function checkForNewerPosition(): Promise<NewerPosition | null> {
|
async function checkForNewerPosition(): Promise<NewerPosition | null> {
|
||||||
if (!unref(trackingEnabled) || saveTimer || saving) return null
|
if (!unref(trackingEnabled) || saveTimer || saving) return null
|
||||||
const res = await api(`/api/v1/books/files/${fileId}/progress`)
|
const data = await fetchFileProgress(fileId)
|
||||||
if (!res.ok || saveTimer || saving) return null
|
if (!data || saveTimer || saving) return null
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
|
||||||
const stamp = positionStamp(data)
|
const stamp = positionStamp(data)
|
||||||
if (stamp <= knownServerStamp) return null
|
if (stamp <= knownServerStamp) return null
|
||||||
if (normalizeString(data.cfi) === cfi.value && normalizeString(data.mediaOverlayFragment) === mediaOverlayFragment.value) {
|
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' })
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ export const SERVER_FEATURES = [
|
|||||||
* `/:attemptId/marks` and `PATCH /:attemptId/rating`.
|
* `/:attemptId/marks` and `PATCH /:attemptId/rating`.
|
||||||
*/
|
*/
|
||||||
"reading-history",
|
"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;
|
] as const;
|
||||||
|
|
||||||
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export * from "./dictionary";
|
|||||||
export * from "./achievement";
|
export * from "./achievement";
|
||||||
export * from "./activity";
|
export * from "./activity";
|
||||||
export * from "./reading-history";
|
export * from "./reading-history";
|
||||||
|
export * from "./reading-progress";
|
||||||
export * from "./reading-session";
|
export * from "./reading-session";
|
||||||
export * from "./reading-session-source-bucket";
|
export * from "./reading-session-source-bucket";
|
||||||
export * from "./shared-reading-insights";
|
export * from "./shared-reading-insights";
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import { BookRepository } from './book.repository';
|
|||||||
import { BookService } from './book.service';
|
import { BookService } from './book.service';
|
||||||
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
||||||
import { ReadingAttemptController } from './reading-attempt.controller';
|
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 { AudiobookEbookProgressSyncService } from './audiobook-ebook-progress-sync.service';
|
||||||
import { DeliveryArtifactService } from './delivery-artifact.service';
|
import { DeliveryArtifactService } from './delivery-artifact.service';
|
||||||
import { DeliveryArtifactRepository } from './delivery-artifact.repository';
|
import { DeliveryArtifactRepository } from './delivery-artifact.repository';
|
||||||
@@ -34,7 +36,7 @@ describe('BookModule', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('registers expected controller/providers/exports', () => {
|
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([
|
expect(Reflect.getMetadata('providers', BookModule)).toEqual([
|
||||||
BookService,
|
BookService,
|
||||||
BookRepository,
|
BookRepository,
|
||||||
@@ -48,6 +50,7 @@ describe('BookModule', () => {
|
|||||||
DeliveryArtifactRepository,
|
DeliveryArtifactRepository,
|
||||||
DeliverySelectionService,
|
DeliverySelectionService,
|
||||||
DeliveryHashRegistrationService,
|
DeliveryHashRegistrationService,
|
||||||
|
ReadingProgressService,
|
||||||
]);
|
]);
|
||||||
// AudiolessEpubService is exported so the Kobo download path can strip narration itself,
|
// AudiolessEpubService is exported so the Kobo download path can strip narration itself,
|
||||||
// without routing through BookService and its RequestUser-based access checks.
|
// without routing through BookService and its RequestUser-based access checks.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { BookRepository } from './book.repository';
|
|||||||
import { BookService } from './book.service';
|
import { BookService } from './book.service';
|
||||||
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
import { BookAuthorSortKeyBackfillService } from './book-author-sort-key-backfill.service';
|
||||||
import { ReadingAttemptController } from './reading-attempt.controller';
|
import { ReadingAttemptController } from './reading-attempt.controller';
|
||||||
|
import { ReadingProgressController } from './reading-progress.controller';
|
||||||
|
import { ReadingProgressService } from './reading-progress.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -52,7 +54,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
|
|||||||
AchievementModule,
|
AchievementModule,
|
||||||
PositionConverterModule,
|
PositionConverterModule,
|
||||||
],
|
],
|
||||||
controllers: [BookController, ReadingAttemptController],
|
controllers: [BookController, ReadingAttemptController, ReadingProgressController],
|
||||||
providers: [
|
providers: [
|
||||||
BookService,
|
BookService,
|
||||||
BookRepository,
|
BookRepository,
|
||||||
@@ -66,6 +68,7 @@ import { ReadingAttemptController } from './reading-attempt.controller';
|
|||||||
DeliveryArtifactRepository,
|
DeliveryArtifactRepository,
|
||||||
DeliverySelectionService,
|
DeliverySelectionService,
|
||||||
DeliveryHashRegistrationService,
|
DeliveryHashRegistrationService,
|
||||||
|
ReadingProgressService,
|
||||||
],
|
],
|
||||||
exports: [BookService, BookReadService, BookQueryBuilder, AudiolessEpubService, DeliveryArtifactService, DeliverySelectionService],
|
exports: [BookService, BookReadService, BookQueryBuilder, AudiolessEpubService, DeliveryArtifactService, DeliverySelectionService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1700,6 +1700,9 @@ export class BookRepository {
|
|||||||
koboLocationValue: readingProgress.koboLocationValue,
|
koboLocationValue: readingProgress.koboLocationValue,
|
||||||
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
|
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
|
||||||
koreaderProgress: readingProgress.koreaderProgress,
|
koreaderProgress: readingProgress.koreaderProgress,
|
||||||
|
narrationPercentage: readingProgress.narrationPercentage,
|
||||||
|
narrationUpdatedAt: readingProgress.narrationUpdatedAt,
|
||||||
|
textUpdatedAt: readingProgress.textUpdatedAt,
|
||||||
updatedAt: readingProgress.updatedAt,
|
updatedAt: readingProgress.updatedAt,
|
||||||
lastReadAt: readingProgress.lastReadAt,
|
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": {
|
"byPermission": {
|
||||||
"(none)": 294,
|
"(none)": 297,
|
||||||
"Permission.ManageMetadataConfig": 41,
|
"Permission.ManageMetadataConfig": 41,
|
||||||
"Permission.EmailSend": 34,
|
"Permission.EmailSend": 34,
|
||||||
"Permission.LibraryEditMetadata": 54,
|
"Permission.LibraryEditMetadata": 54,
|
||||||
|
|||||||
@@ -1210,5 +1210,41 @@
|
|||||||
"isPublic": false,
|
"isPublic": false,
|
||||||
"allowDefault": false,
|
"allowDefault": false,
|
||||||
"useGuards": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user