mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): bust stale covers after bulk refresh via pre-bump and consistent coverUrl usage
This commit is contained in:
@@ -16,7 +16,7 @@ A self-hosted library management and reading platform for ebooks, PDFs, audioboo
|
||||
-->
|
||||
|
||||
[](https://bookorbit.app)
|
||||
[](https://demo.bookorbit.app)
|
||||
[](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)
|
||||
[](https://github.com/bookorbit/bookorbit/discussions)
|
||||
[](https://github.com/bookorbit/bookorbit/blob/main/docs/CONTRIBUTING.md)
|
||||
[](https://github.com/bookorbit/bookorbit/pkgs/container/bookorbit)
|
||||
@@ -64,11 +64,11 @@ BookOrbit is a self-hosted digital library and reading platform. Organize and re
|
||||
|
||||
## Live Demo
|
||||
|
||||
Try BookOrbit without installing anything.
|
||||
Explore BookOrbit instantly, no installation or account required.
|
||||
|
||||
**[Open Demo](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)** - no account needed, the link logs you in automatically.
|
||||
**[🚀 Launch Demo](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)**
|
||||
|
||||
The demo is pre-loaded with a sample library of public domain books and resets automatically.
|
||||
Note: The demo includes a sample library of public domain books. Some features are limited in the public demo, self-hosting BookOrbit provides the full experience.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
@@ -12,6 +13,7 @@ const props = defineProps<{
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
@@ -31,10 +33,6 @@ function handleClick() {
|
||||
router.push({ name: 'series-detail', params: { seriesName: seriesName.value }, query: { from: route.fullPath } })
|
||||
}
|
||||
|
||||
function thumbnailUrl(bookId: number): string {
|
||||
return `/api/v1/books/${bookId}/thumbnail`
|
||||
}
|
||||
|
||||
function tileClass(index: number): string {
|
||||
if (tileCount.value <= 1) return 'col-span-2 row-span-2'
|
||||
if (tileCount.value === 2) return 'row-span-2'
|
||||
@@ -56,7 +54,7 @@ function tileClass(index: number): string {
|
||||
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
|
||||
<img
|
||||
v-if="!failedCovers.has(bookId)"
|
||||
:src="thumbnailUrl(bookId)"
|
||||
:src="coverUrl(bookId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
|
||||
@@ -12,6 +12,23 @@ const mocks = vi.hoisted(() => ({
|
||||
exportBooks: vi.fn<(bookIds: number[], includeAll?: boolean, formatGroup?: string) => Promise<void>>(),
|
||||
}))
|
||||
|
||||
function makeSseStream(lines: string[]): { ok: true; body: { getReader: () => { read: () => Promise<{ done: boolean; value?: Uint8Array }> } } } {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (index >= lines.length) return { done: true as const, value: undefined }
|
||||
const line = lines[index++]
|
||||
return { done: false as const, value: encoder.encode(line + '\n') }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: mocks.api,
|
||||
}))
|
||||
@@ -396,4 +413,122 @@ describe('useBookBulkActions', () => {
|
||||
expect(onDeleted).toHaveBeenCalledWith([])
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('Deleted 500 books')
|
||||
})
|
||||
|
||||
it('bumps versions for all selected IDs after SSE stream even when no events are received', async () => {
|
||||
mocks.api.mockResolvedValue(makeSseStream([]))
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set([5, 6, 7]))
|
||||
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkRefreshMetadata()
|
||||
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(5)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(6)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('bumps version per SSE event and not before the stream for bulk metadata refresh', async () => {
|
||||
const order: string[] = []
|
||||
mocks.bumpVersion.mockImplementation((id: number) => order.push(`bump:${id}`))
|
||||
|
||||
mocks.api.mockImplementation(async () => {
|
||||
order.push('api:called')
|
||||
return makeSseStream([
|
||||
`data: ${JSON.stringify({ bookId: 10, success: true })}`,
|
||||
`data: ${JSON.stringify({ bookId: 20, success: true })}`,
|
||||
`data: ${JSON.stringify({ done: true, processed: 2, failed: 0 })}`,
|
||||
])
|
||||
})
|
||||
|
||||
const selectedIds = ref(new Set([10, 20]))
|
||||
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkRefreshMetadata()
|
||||
|
||||
expect(order.indexOf('api:called')).toBeLessThan(order.indexOf('bump:10'))
|
||||
expect(order.indexOf('api:called')).toBeLessThan(order.indexOf('bump:20'))
|
||||
})
|
||||
|
||||
it('bumps version exactly once per book when all SSE events are received for bulk metadata refresh', async () => {
|
||||
mocks.api.mockResolvedValue(
|
||||
makeSseStream([
|
||||
`data: ${JSON.stringify({ bookId: 10, success: true })}`,
|
||||
`data: ${JSON.stringify({ bookId: 20, success: true })}`,
|
||||
`data: ${JSON.stringify({ done: true, processed: 2, failed: 0 })}`,
|
||||
]),
|
||||
)
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set([10, 20]))
|
||||
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkRefreshMetadata()
|
||||
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 10).length).toBe(1)
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 20).length).toBe(1)
|
||||
})
|
||||
|
||||
it('bumps versions for books that receive no SSE event in finally for bulk metadata refresh', async () => {
|
||||
mocks.api.mockResolvedValue(
|
||||
makeSseStream([`data: ${JSON.stringify({ bookId: 10, success: true })}`, `data: ${JSON.stringify({ done: true, processed: 1, failed: 0 })}`]),
|
||||
)
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set([10, 20]))
|
||||
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkRefreshMetadata()
|
||||
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(10)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(20)
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 10).length).toBe(1)
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 20).length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not bump for query-selection books that received no SSE event', async () => {
|
||||
mocks.api.mockResolvedValue(
|
||||
makeSseStream([`data: ${JSON.stringify({ bookId: 42, success: true })}`, `data: ${JSON.stringify({ done: true, processed: 1, failed: 0 })}`]),
|
||||
)
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set<number>())
|
||||
const querySelection = ref<QuerySelectionState | null>(makeQuerySelection())
|
||||
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn(), undefined, undefined, querySelection)
|
||||
|
||||
await handleBulkRefreshMetadata()
|
||||
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('bumps version per SSE event and bumps missed books in finally for bulk re-extract cover', async () => {
|
||||
mocks.api.mockResolvedValue(
|
||||
makeSseStream([`data: ${JSON.stringify({ bookId: 30, updated: true })}`, `data: ${JSON.stringify({ done: true, processed: 2, updated: 1 })}`]),
|
||||
)
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set([30, 40]))
|
||||
const { handleBulkReExtractCover } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkReExtractCover()
|
||||
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(30)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(40)
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 30).length).toBe(1)
|
||||
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 40).length).toBe(1)
|
||||
})
|
||||
|
||||
it('bumps all re-extract IDs in finally when stream delivers no events', async () => {
|
||||
mocks.api.mockResolvedValue(makeSseStream([]))
|
||||
mocks.bumpVersion.mockReset()
|
||||
|
||||
const selectedIds = ref(new Set([11, 22]))
|
||||
const { handleBulkReExtractCover } = useBookBulkActions(selectedIds, vi.fn())
|
||||
|
||||
await handleBulkReExtractCover()
|
||||
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(11)
|
||||
expect(mocks.bumpVersion).toHaveBeenCalledWith(22)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
|
||||
const mockSelectionMode = { value: false }
|
||||
const mockSelectedIds = { value: new Set<number>() }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
enterSelectionMode: vi.fn<() => void>(),
|
||||
exitSelectionMode: vi.fn<() => void>(),
|
||||
toggleBook: vi.fn<(id: number) => void>(),
|
||||
promptDelete: vi.fn<(id: number) => void>(),
|
||||
cancelDelete: vi.fn<() => void>(),
|
||||
confirmDelete: vi.fn<() => void>(),
|
||||
useBookBulkActions: vi.fn<(...args: unknown[]) => object>(() => ({
|
||||
handleBulkRefreshMetadata: vi.fn<() => void>(),
|
||||
handleBulkReExtractCover: vi.fn<() => void>(),
|
||||
handleBulkSetStatus: vi.fn<() => void>(),
|
||||
handleBulkSetRating: vi.fn<() => void>(),
|
||||
handleBulkSetField: vi.fn<() => void>(),
|
||||
handleBulkSetMetadataLock: vi.fn<() => void>(),
|
||||
handleBulkUpdateTags: vi.fn<() => void>(),
|
||||
handleDeleteSelected: vi.fn<() => void>(),
|
||||
handleDownloadFiles: vi.fn<() => void>(),
|
||||
inFlight: { value: null },
|
||||
})),
|
||||
useDeleteBookCallback: null as ((id: number) => void) | null,
|
||||
}))
|
||||
|
||||
vi.mock('../useTableViewControls', () => ({
|
||||
useTableViewControls: () => ({ tableRef: { value: null } }),
|
||||
}))
|
||||
|
||||
vi.mock('../useBookViewSelection', () => ({
|
||||
useBookViewSelection: () => ({
|
||||
selectedIds: mockSelectedIds,
|
||||
selectionMode: mockSelectionMode,
|
||||
enterSelectionMode: mocks.enterSelectionMode,
|
||||
exitSelectionMode: mocks.exitSelectionMode,
|
||||
toggleBook: mocks.toggleBook,
|
||||
handleSelect: vi.fn<() => void>(),
|
||||
toggleSelectionMode: vi.fn<() => void>(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../useDeleteBook', () => ({
|
||||
useDeleteBook: (cb: (id: number) => void) => {
|
||||
mocks.useDeleteBookCallback = cb
|
||||
return {
|
||||
pendingId: { value: null },
|
||||
deleting: { value: false },
|
||||
promptDelete: mocks.promptDelete,
|
||||
cancelDelete: mocks.cancelDelete,
|
||||
confirmDelete: mocks.confirmDelete,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../useBookBulkActions', () => ({
|
||||
useBookBulkActions: mocks.useBookBulkActions,
|
||||
}))
|
||||
|
||||
import { useBookTableShell } from '../useBookTableShell'
|
||||
|
||||
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
||||
return {
|
||||
id: 1,
|
||||
status: 'present',
|
||||
title: 'Test Book',
|
||||
authors: ['Test Author'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: null,
|
||||
metadataScore: null,
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
lockedFields: [],
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
pageCount: null,
|
||||
isbn13: null,
|
||||
narrators: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useBookTableShell', () => {
|
||||
beforeEach(() => {
|
||||
mockSelectionMode.value = false
|
||||
mockSelectedIds.value = new Set()
|
||||
mocks.enterSelectionMode.mockClear()
|
||||
mocks.exitSelectionMode.mockClear()
|
||||
mocks.toggleBook.mockClear()
|
||||
mocks.promptDelete.mockClear()
|
||||
mocks.cancelDelete.mockClear()
|
||||
mocks.confirmDelete.mockClear()
|
||||
mocks.useBookBulkActions.mockClear()
|
||||
})
|
||||
|
||||
it('wires useBookBulkActions without an onBulkRefreshCompleted callback', () => {
|
||||
const books = ref([makeBook()])
|
||||
useBookTableShell({ books })
|
||||
|
||||
expect(mocks.useBookBulkActions).toHaveBeenCalledOnce()
|
||||
const call = mocks.useBookBulkActions.mock.calls[0]!
|
||||
const [, , , fourthArg] = call
|
||||
expect(fourthArg).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes querySelection to useBookBulkActions', () => {
|
||||
const books = ref([makeBook()])
|
||||
const querySelection = ref(null)
|
||||
useBookTableShell({ books, querySelection })
|
||||
|
||||
const call = mocks.useBookBulkActions.mock.calls[0]!
|
||||
const [, , , , fifthArg] = call
|
||||
expect(fifthArg).toBe(querySelection)
|
||||
})
|
||||
|
||||
it('passes the books ref to useBookBulkActions', () => {
|
||||
const books = ref([makeBook()])
|
||||
useBookTableShell({ books })
|
||||
|
||||
const call = mocks.useBookBulkActions.mock.calls[0]!
|
||||
const [, , thirdArg] = call
|
||||
expect(thirdArg).toBe(books)
|
||||
})
|
||||
|
||||
describe('handleBookAction', () => {
|
||||
it('sets quickViewBookId and opens quick view for quick-view action', () => {
|
||||
const books = ref([makeBook({ id: 7 })])
|
||||
const { handleBookAction, quickViewBookId, quickViewOpen } = useBookTableShell({ books })
|
||||
|
||||
handleBookAction(makeBook({ id: 7 }), 'quick-view')
|
||||
|
||||
expect(quickViewBookId.value).toBe(7)
|
||||
expect(quickViewOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('enters selection mode and toggles the book for add-to-collection when not in selection mode', () => {
|
||||
mockSelectionMode.value = false
|
||||
const books = ref([makeBook({ id: 3 })])
|
||||
const { handleBookAction, addToCollectionOpen } = useBookTableShell({ books })
|
||||
|
||||
handleBookAction(makeBook({ id: 3 }), 'add-to-collection')
|
||||
|
||||
expect(mocks.enterSelectionMode).toHaveBeenCalledOnce()
|
||||
expect(mocks.toggleBook).toHaveBeenCalledWith(3)
|
||||
expect(addToCollectionOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('skips enterSelectionMode for add-to-collection when already in selection mode', () => {
|
||||
mockSelectionMode.value = true
|
||||
const books = ref([makeBook({ id: 3 })])
|
||||
const { handleBookAction, addToCollectionOpen } = useBookTableShell({ books })
|
||||
|
||||
handleBookAction(makeBook({ id: 3 }), 'add-to-collection')
|
||||
|
||||
expect(mocks.enterSelectionMode).not.toHaveBeenCalled()
|
||||
expect(mocks.toggleBook).not.toHaveBeenCalled()
|
||||
expect(addToCollectionOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('calls promptDelete for the delete action', () => {
|
||||
const books = ref([makeBook({ id: 5 })])
|
||||
const { handleBookAction } = useBookTableShell({ books })
|
||||
|
||||
handleBookAction(makeBook({ id: 5 }), 'delete')
|
||||
|
||||
expect(mocks.promptDelete).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleTableBookUpdate', () => {
|
||||
it('replaces the matching book in the list', () => {
|
||||
const books = ref([makeBook({ id: 1, title: 'Original' }), makeBook({ id: 2, title: 'Other' })])
|
||||
const { handleTableBookUpdate } = useBookTableShell({ books })
|
||||
|
||||
handleTableBookUpdate(makeBook({ id: 1, title: 'Updated' }))
|
||||
|
||||
expect(books.value[0]?.title).toBe('Updated')
|
||||
expect(books.value[1]?.title).toBe('Other')
|
||||
})
|
||||
|
||||
it('does not modify the list when the book id is not found', () => {
|
||||
const books = ref([makeBook({ id: 1, title: 'Unchanged' })])
|
||||
const { handleTableBookUpdate } = useBookTableShell({ books })
|
||||
|
||||
handleTableBookUpdate(makeBook({ id: 999, title: 'Ghost' }))
|
||||
|
||||
expect(books.value[0]?.title).toBe('Unchanged')
|
||||
})
|
||||
|
||||
it('only replaces the first matching index when duplicate ids exist', () => {
|
||||
const books = ref([makeBook({ id: 1, title: 'A' }), makeBook({ id: 1, title: 'B' })])
|
||||
const { handleTableBookUpdate } = useBookTableShell({ books })
|
||||
|
||||
handleTableBookUpdate(makeBook({ id: 1, title: 'Updated' }))
|
||||
|
||||
expect(books.value[0]?.title).toBe('Updated')
|
||||
expect(books.value[1]?.title).toBe('B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onDeleted single-book callback', () => {
|
||||
it('removes the deleted book from the list', () => {
|
||||
const books = ref([makeBook({ id: 10 }), makeBook({ id: 20 })])
|
||||
useBookTableShell({ books })
|
||||
|
||||
mocks.useDeleteBookCallback!(10)
|
||||
|
||||
expect(books.value.map((b) => b.id)).toEqual([20])
|
||||
})
|
||||
|
||||
it('leaves list unchanged when deleted id is not present', () => {
|
||||
const books = ref([makeBook({ id: 10 })])
|
||||
useBookTableShell({ books })
|
||||
|
||||
mocks.useDeleteBookCallback!(99)
|
||||
|
||||
expect(books.value.map((b) => b.id)).toEqual([10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk delete callback', () => {
|
||||
it('removes all deleted books and exits selection mode', () => {
|
||||
const books = ref([makeBook({ id: 1 }), makeBook({ id: 2 }), makeBook({ id: 3 })])
|
||||
useBookTableShell({ books })
|
||||
|
||||
const call = mocks.useBookBulkActions.mock.calls[0]!
|
||||
const [, bulkDeleteCallback] = call as [unknown, (ids: number[]) => void]
|
||||
bulkDeleteCallback([1, 3])
|
||||
|
||||
expect(books.value.map((b) => b.id)).toEqual([2])
|
||||
expect(mocks.exitSelectionMode).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exits selection mode even when ids array is empty', () => {
|
||||
const books = ref([makeBook({ id: 1 }), makeBook({ id: 2 })])
|
||||
useBookTableShell({ books })
|
||||
|
||||
const call = mocks.useBookBulkActions.mock.calls[0]!
|
||||
const [, bulkDeleteCallback] = call as [unknown, (ids: number[]) => void]
|
||||
bulkDeleteCallback([])
|
||||
|
||||
expect(books.value.map((b) => b.id)).toEqual([1, 2])
|
||||
expect(mocks.exitSelectionMode).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes delete control refs and handlers', () => {
|
||||
const books = ref([makeBook()])
|
||||
const shell = useBookTableShell({ books })
|
||||
|
||||
expect(shell.deleteBookId).toBeDefined()
|
||||
expect(shell.deletingBook).toBeDefined()
|
||||
expect(shell.promptDelete).toBe(mocks.promptDelete)
|
||||
expect(shell.cancelDelete).toBe(mocks.cancelDelete)
|
||||
expect(shell.confirmDelete).toBe(mocks.confirmDelete)
|
||||
})
|
||||
|
||||
it('exposes dialog open refs initialised to false/null', () => {
|
||||
const books = ref([makeBook()])
|
||||
const shell = useBookTableShell({ books })
|
||||
|
||||
expect(shell.addToCollectionOpen.value).toBe(false)
|
||||
expect(shell.bulkTagsOpen.value).toBe(false)
|
||||
expect(shell.sendBookOpen.value).toBe(false)
|
||||
expect(shell.quickViewOpen.value).toBe(false)
|
||||
expect(shell.quickViewBookId.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('useCoverVersions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-01T10:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const mod = await import('../useCoverVersions')
|
||||
return mod.useCoverVersions()
|
||||
}
|
||||
|
||||
it('returns base thumbnail URL when no version is stored', async () => {
|
||||
const { coverUrl } = await load()
|
||||
expect(coverUrl(42)).toBe('/api/v1/books/42/thumbnail')
|
||||
})
|
||||
|
||||
it('returns base cover URL when type is cover and no version is stored', async () => {
|
||||
const { coverUrl } = await load()
|
||||
expect(coverUrl(42, 'cover')).toBe('/api/v1/books/42/cover')
|
||||
})
|
||||
|
||||
it('returns versioned thumbnail URL after bumpVersion', async () => {
|
||||
const { coverUrl, bumpVersion } = await load()
|
||||
bumpVersion(42)
|
||||
const now = Date.now()
|
||||
expect(coverUrl(42)).toBe(`/api/v1/books/42/thumbnail?t=${now}`)
|
||||
})
|
||||
|
||||
it('returns versioned cover URL after bumpVersion', async () => {
|
||||
const { coverUrl, bumpVersion } = await load()
|
||||
bumpVersion(7)
|
||||
const now = Date.now()
|
||||
expect(coverUrl(7, 'cover')).toBe(`/api/v1/books/7/cover?t=${now}`)
|
||||
})
|
||||
|
||||
it('does not add ?t= to a different book that was not bumped', async () => {
|
||||
const { coverUrl, bumpVersion } = await load()
|
||||
bumpVersion(1)
|
||||
expect(coverUrl(2)).toBe('/api/v1/books/2/thumbnail')
|
||||
})
|
||||
|
||||
it('getVersion returns undefined before any bump', async () => {
|
||||
const { getVersion } = await load()
|
||||
expect(getVersion(99)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getVersion returns the stored timestamp after bumpVersion', async () => {
|
||||
const { getVersion, bumpVersion } = await load()
|
||||
bumpVersion(5)
|
||||
expect(getVersion(5)).toBe(Date.now())
|
||||
})
|
||||
|
||||
it('bumpVersion with a later timestamp overwrites the earlier one', async () => {
|
||||
const { coverUrl, bumpVersion } = await load()
|
||||
bumpVersion(3)
|
||||
const t1 = Date.now()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
bumpVersion(3)
|
||||
const t2 = Date.now()
|
||||
|
||||
expect(t2).toBeGreaterThan(t1)
|
||||
expect(coverUrl(3)).toBe(`/api/v1/books/3/thumbnail?t=${t2}`)
|
||||
})
|
||||
|
||||
it('singleton: versions bumped in one composable instance are visible in another', async () => {
|
||||
const mod = await import('../useCoverVersions')
|
||||
const a = mod.useCoverVersions()
|
||||
const b = mod.useCoverVersions()
|
||||
|
||||
a.bumpVersion(10)
|
||||
expect(b.coverUrl(10)).toBe(`/api/v1/books/10/thumbnail?t=${Date.now()}`)
|
||||
})
|
||||
|
||||
it('persists bumped version to localStorage', async () => {
|
||||
const { bumpVersion } = await load()
|
||||
bumpVersion(11)
|
||||
const stored = JSON.parse(localStorage.getItem('cover-versions') ?? '[]') as [number, number][]
|
||||
expect(stored).toContainEqual([11, Date.now()])
|
||||
})
|
||||
|
||||
it('loads pre-existing versions from localStorage on init', async () => {
|
||||
const ts = Date.now()
|
||||
localStorage.setItem('cover-versions', JSON.stringify([[20, ts]]))
|
||||
const { coverUrl } = await load()
|
||||
expect(coverUrl(20)).toBe(`/api/v1/books/20/thumbnail?t=${ts}`)
|
||||
})
|
||||
|
||||
it('returns empty map when localStorage contains corrupt data', async () => {
|
||||
localStorage.setItem('cover-versions', 'not-json')
|
||||
const { coverUrl } = await load()
|
||||
expect(coverUrl(1)).toBe('/api/v1/books/1/thumbnail')
|
||||
})
|
||||
|
||||
it('handles localStorage setItem quota errors silently', async () => {
|
||||
const { bumpVersion, coverUrl } = await load()
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('QuotaExceededError')
|
||||
})
|
||||
expect(() => bumpVersion(7)).not.toThrow()
|
||||
expect(coverUrl(7)).toBe(`/api/v1/books/7/thumbnail?t=${Date.now()}`)
|
||||
})
|
||||
})
|
||||
@@ -189,6 +189,7 @@ export function useBookBulkActions(
|
||||
for (const id of ids) {
|
||||
if (refreshFeedback.getFeedback(id)?.state === 'refreshing') {
|
||||
refreshFeedback.markFailed(id, 'Metadata refresh interrupted')
|
||||
bumpVersion(id)
|
||||
}
|
||||
}
|
||||
clearRefreshing(ids)
|
||||
@@ -229,6 +230,7 @@ export function useBookBulkActions(
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let updated = 0
|
||||
const bumpedIds = new Set<number>()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -238,6 +240,7 @@ export function useBookBulkActions(
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) {
|
||||
bumpedIds.add(data.bookId)
|
||||
bumpVersion(data.bookId)
|
||||
clearRefreshing([data.bookId])
|
||||
inFlight.value = { label: 'Re-extracting covers', processed: inFlight.value!.processed + 1, total }
|
||||
@@ -252,6 +255,9 @@ export function useBookBulkActions(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
if (!bumpedIds.has(id)) bumpVersion(id)
|
||||
}
|
||||
clearRefreshing(ids)
|
||||
inFlight.value = null
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ interface BookTableShellOptions {
|
||||
total?: Ref<number>
|
||||
loading?: Ref<boolean>
|
||||
exitSelectionMode?: () => void
|
||||
loadFn: (force?: boolean) => void | Promise<void>
|
||||
querySelection?: Ref<QuerySelectionState | null>
|
||||
}
|
||||
|
||||
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
|
||||
|
||||
export function useBookTableShell({ books, loadFn, querySelection }: BookTableShellOptions) {
|
||||
export function useBookTableShell({ books, querySelection }: BookTableShellOptions) {
|
||||
const tableControls = useTableViewControls()
|
||||
const selection = useBookViewSelection(books)
|
||||
|
||||
@@ -40,7 +39,7 @@ export function useBookTableShell({ books, loadFn, querySelection }: BookTableSh
|
||||
selection.exitSelectionMode()
|
||||
},
|
||||
books,
|
||||
() => loadFn(true),
|
||||
undefined,
|
||||
querySelection,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { BookOpen, Play } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useCurrentlyReadingWidget } from '../../composables/useCurrentlyReadingWidget'
|
||||
|
||||
const { data, loading, error } = useCurrentlyReadingWidget()
|
||||
const router = useRouter()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
function goToBook(bookId: number) {
|
||||
void router.push({ name: 'book-detail', params: { bookId } })
|
||||
@@ -61,12 +63,7 @@ function continueReading(bookId: number, fileId: number | null, fileFormat: stri
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="h-14 w-9 shrink-0 overflow-hidden rounded shadow-sm">
|
||||
<img
|
||||
v-if="book.hasCover"
|
||||
:src="`/api/v1/books/${book.bookId}/thumbnail`"
|
||||
:alt="book.title ?? 'Book cover'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<img v-if="book.hasCover" :src="coverUrl(book.bookId)" :alt="book.title ?? 'Book cover'" class="h-full w-full object-cover" />
|
||||
<div v-else class="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
|
||||
<BookOpen :size="12" />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { Highlighter, BookOpen, ExternalLink } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useHighlightOfTheDayWidget } from '../../composables/useHighlightOfTheDayWidget'
|
||||
|
||||
const { data, loading, error } = useHighlightOfTheDayWidget()
|
||||
const router = useRouter()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
function goToBook() {
|
||||
if (!data.value) return
|
||||
@@ -46,7 +48,7 @@ function goToBook() {
|
||||
</blockquote>
|
||||
<button class="flex cursor-pointer items-center gap-2 rounded-lg pb-1 pl-1 text-left transition-colors hover:bg-muted/40" @click="goToBook">
|
||||
<div v-if="data.hasCover" class="h-9 w-6 shrink-0 overflow-hidden rounded shadow-sm">
|
||||
<img :src="`/api/v1/books/${data.bookId}/thumbnail`" :alt="data.bookTitle ?? 'Cover'" class="h-full w-full object-cover" />
|
||||
<img :src="coverUrl(data.bookId)" :alt="data.bookTitle ?? 'Cover'" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="flex h-8 w-5 shrink-0 items-center justify-center rounded bg-muted">
|
||||
<BookOpen :size="10" class="text-muted-foreground" />
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { Clock, BookOpen, Play } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useLongWaitWidget } from '../../composables/useLongWaitWidget'
|
||||
|
||||
const { data, loading, error } = useLongWaitWidget()
|
||||
const router = useRouter()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
function goToBook() {
|
||||
if (!data.value) return
|
||||
@@ -54,7 +56,7 @@ function startReading() {
|
||||
<div v-else class="flex flex-1 flex-col">
|
||||
<div class="flex flex-1 items-center justify-center gap-4">
|
||||
<button class="h-24 w-18 shrink-0 cursor-pointer overflow-hidden rounded shadow-sm transition-opacity hover:opacity-80" @click="goToBook">
|
||||
<img v-if="data.hasCover" :src="`/api/v1/books/${data.bookId}/thumbnail`" :alt="data.title ?? 'Cover'" class="h-full w-full object-cover" />
|
||||
<img v-if="data.hasCover" :src="coverUrl(data.bookId)" :alt="data.title ?? 'Cover'" class="h-full w-full object-cover" />
|
||||
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
|
||||
<BookOpen :size="14" class="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Gem, Star, BookOpen, BookMarked, Check } from 'lucide-vue-next'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||
import { useNeglectedGemsWidget } from '../../composables/useNeglectedGemsWidget'
|
||||
|
||||
const { data, loading, error } = useNeglectedGemsWidget()
|
||||
const router = useRouter()
|
||||
const { setStatus } = useBookStatus()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
const displayIndex = ref(0)
|
||||
const currentGem = computed(() => data.value?.gems[displayIndex.value] ?? null)
|
||||
@@ -59,12 +61,7 @@ async function addToQueue() {
|
||||
<!-- Gem -->
|
||||
<div v-else-if="currentGem" class="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
<button class="h-19 w-13 cursor-pointer overflow-hidden rounded shadow-sm transition-opacity hover:opacity-80" @click="goToBook">
|
||||
<img
|
||||
v-if="currentGem.hasCover"
|
||||
:src="`/api/v1/books/${currentGem.bookId}/thumbnail`"
|
||||
:alt="currentGem.title ?? 'Cover'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<img v-if="currentGem.hasCover" :src="coverUrl(currentGem.bookId)" :alt="currentGem.title ?? 'Cover'" class="h-full w-full object-cover" />
|
||||
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
|
||||
<BookOpen :size="14" class="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CalendarRange } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { UserReadingSessionTimeline, UserReadingSessionTimelineItem } from '@bookorbit/types'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { fetchUserReadingSessionTimeline, updateUserReadingSessionTimelineSession } from '../../api/statistics.api'
|
||||
import { useStatisticsConfig } from '../../composables/useStatisticsConfig'
|
||||
@@ -52,6 +53,7 @@ interface PointerOffset {
|
||||
}
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
const now = new Date()
|
||||
const selectedYear = ref(getUtcIsoWeekYear(now))
|
||||
@@ -214,7 +216,7 @@ function makeOption(data: SessionSegment[], preview: DragPreview | null) {
|
||||
const title = escapeHtml(d.bookTitle ?? 'Reading session')
|
||||
const format = escapeHtml(d.bookFormat ?? 'Unknown')
|
||||
const cover = d.bookId
|
||||
? `<img src="/api/v1/books/${d.bookId}/thumbnail" alt="Cover" style="width:32px;height:48px;object-fit:cover;border-radius:4px;" />`
|
||||
? `<img src="${coverUrl(d.bookId)}" alt="Cover" style="width:32px;height:48px;object-fit:cover;border-radius:4px;" />`
|
||||
: ''
|
||||
|
||||
return [
|
||||
|
||||
@@ -183,7 +183,6 @@ const {
|
||||
handleTableBookUpdate,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
loadFn: () => load(true),
|
||||
})
|
||||
|
||||
const metadataExportOpen = ref(false)
|
||||
|
||||
@@ -406,7 +406,6 @@ const {
|
||||
handleTableBookUpdate,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
loadFn: () => load(true),
|
||||
querySelection,
|
||||
})
|
||||
|
||||
|
||||
@@ -191,7 +191,6 @@ const {
|
||||
handleTableBookUpdate,
|
||||
} = useBookTableShell({
|
||||
books,
|
||||
loadFn: () => load(true),
|
||||
})
|
||||
|
||||
const metadataExportOpen = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user