mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(series): add series collapse with per-library preferences
This commit is contained in:
@@ -204,6 +204,10 @@ function getIconComponent(name: string) {
|
||||
<SlidersHorizontal :size="14" class="mr-2" />
|
||||
Display
|
||||
</DropdownMenuItem>
|
||||
<template v-if="$slots['mobile-menu']">
|
||||
<DropdownMenuSeparator />
|
||||
<slot name="mobile-menu" />
|
||||
</template>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="emit('toggle-selection')">
|
||||
<CheckSquare v-if="selectionMode" :size="14" class="mr-2" />
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
||||
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) => bookId > 0).slice(0, 4))
|
||||
const tileCount = computed(() => Math.max(coverIds.value.length, 1))
|
||||
|
||||
const failedCovers = ref(new Set<number>())
|
||||
|
||||
function handleCoverError(bookId: number) {
|
||||
failedCovers.value = new Set([...failedCovers.value, bookId])
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
router.push(`/series/${encodeURIComponent(seriesName.value)}`)
|
||||
}
|
||||
|
||||
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'
|
||||
if (tileCount.value === 3 && index === 0) return 'row-span-2'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group flex flex-col @container cursor-pointer" @click="handleClick">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform] duration-150 will-change-transform group-hover:shadow-xl group-hover:scale-[1.02]"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<!-- Adaptive cover mosaic -->
|
||||
<div class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
<template v-for="(bookId, i) in coverIds" :key="bookId">
|
||||
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
|
||||
<img
|
||||
v-if="!failedCovers.has(bookId)"
|
||||
:src="thumbnailUrl(bookId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="() => handleCoverError(bookId)"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="coverIds.length === 0" class="relative overflow-hidden" :class="tileClass(0)" data-testid="series-cover-fallback">
|
||||
<BookCoverPlaceholder title="" author-line="" :is-audio="false" seed="series-empty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Count badge -->
|
||||
<div
|
||||
class="absolute right-1.5 top-1.5 z-10 rounded-sm bg-black/70 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white group-hover:opacity-0 transition-opacity duration-150"
|
||||
data-testid="series-count-badge"
|
||||
>
|
||||
{{ collapsed.bookCount }}
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col p-2 bg-black/70 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<div class="shrink-0 flex flex-col">
|
||||
<p class="text-xs font-semibold text-white leading-tight" :class="hoverTitleClampClass">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import BookCoverCard from './BookCoverCard.vue'
|
||||
import CollapsedSeriesCard from './CollapsedSeriesCard.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
@@ -95,7 +96,9 @@ const scrollerStyle = computed(() => ({
|
||||
>
|
||||
<template #default="{ item: book }">
|
||||
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" />
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="book"
|
||||
:selection-mode="selectionMode"
|
||||
:selected="isSelected?.(book.id) ?? false"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
import CollapsedSeriesCard from '../CollapsedSeriesCard.vue'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: mockRouterPush }) }
|
||||
})
|
||||
|
||||
vi.mock('../BookCoverPlaceholder.vue', () => ({
|
||||
default: { template: '<div class="book-cover-placeholder" />' },
|
||||
}))
|
||||
|
||||
function makeBook(overrides?: Partial<BookCard>): BookCard {
|
||||
return {
|
||||
id: 1,
|
||||
status: 'present',
|
||||
title: 'Book One',
|
||||
authors: ['Author A'],
|
||||
seriesName: 'The Arc',
|
||||
seriesIndex: 1,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
addedAt: '2024-01-01T00:00:00.000Z',
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
collapsedSeries: {
|
||||
bookCount: 5,
|
||||
readCount: 2,
|
||||
coverBookIds: [1, 2, 3, 4],
|
||||
seriesLatestAddedAt: '2024-06-01T00:00:00.000Z',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CollapsedSeriesCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders four cover tiles when all books are represented by covers', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 6, readCount: 0, coverBookIds: [1, 2, 3, 4, 5, 6], seriesLatestAddedAt: null },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders img elements for each visible cover ID', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 4, readCount: 0, coverBookIds: [10, 20, 30, 40], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const imgs = wrapper.findAll('img')
|
||||
expect(imgs).toHaveLength(4)
|
||||
expect(imgs[0]!.attributes('src')).toBe('/api/v1/books/10/thumbnail')
|
||||
expect(imgs[1]!.attributes('src')).toBe('/api/v1/books/20/thumbnail')
|
||||
})
|
||||
|
||||
it('does not pad missing slots when fewer than four covers exist', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 1, readCount: 0, coverBookIds: [5], seriesLatestAddedAt: null },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows a single fallback tile when coverBookIds is empty', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 3, readCount: 0, coverBookIds: [], seriesLatestAddedAt: null },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(0)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows count badge with bookCount', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
const badge = wrapper.find('[data-testid="series-count-badge"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('5')
|
||||
})
|
||||
|
||||
it('shows the series name only once after removing the footer title', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
const seriesNameInstances = wrapper
|
||||
.findAll('p')
|
||||
.map((node) => node.text())
|
||||
.filter((text) => text === 'The Arc')
|
||||
|
||||
expect(seriesNameInstances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows author name', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
expect(wrapper.text()).toContain('Author A')
|
||||
})
|
||||
|
||||
it('navigates to /series/:encodedName on click', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(`/series/${encodeURIComponent('The Arc')}`)
|
||||
})
|
||||
|
||||
it('encodes series name with special characters in URL', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ seriesName: 'The Wheel & Time' }) },
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(`/series/${encodeURIComponent('The Wheel & Time')}`)
|
||||
})
|
||||
|
||||
it('shows placeholder instead of img after @error fires', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 2, readCount: 0, coverBookIds: [10, 20], seriesLatestAddedAt: null },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const firstImg = wrapper.find('img')
|
||||
expect(firstImg.exists()).toBe(true)
|
||||
|
||||
await firstImg.trigger('error')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses at most 4 cover tiles even if more are provided', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 6, readCount: 0, coverBookIds: [1, 2, 3, 4, 5, 6], seriesLatestAddedAt: null },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type { BookCard, BookQuery, BooksPage, GroupRule, SortSpec } from '@proje
|
||||
|
||||
export type { BookCard }
|
||||
|
||||
export function useBookQuery(libraryId: Ref<number | null>) {
|
||||
export function useBookQuery(libraryId: Ref<number | null>, collapseEnabled: Ref<boolean> = ref(false)) {
|
||||
const items = ref<BookCard[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
@@ -34,6 +34,7 @@ export function useBookQuery(libraryId: Ref<number | null>) {
|
||||
filter: filter.value,
|
||||
sort: sort.value,
|
||||
pagination: { page: pagination.page, size: pagination.size },
|
||||
...(collapseEnabled.value ? { collapseSeries: true } : {}),
|
||||
}
|
||||
|
||||
const res = await api(`/api/v1/libraries/${libraryId.value}/books`, {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { AuthUser } from '@projectx/types'
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
useAuth: vi.fn<() => unknown>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<() => unknown>(),
|
||||
}))
|
||||
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { api } from '@/lib/api'
|
||||
import { useSeriesCollapsePreference } from './useSeriesCollapsePreference'
|
||||
|
||||
const mockUseAuth = vi.mocked(useAuth)
|
||||
const mockApi = vi.mocked(api)
|
||||
|
||||
function makeUser(settings: Record<string, unknown> = {}): AuthUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'tester',
|
||||
name: 'Tester',
|
||||
email: undefined,
|
||||
active: true,
|
||||
isSuperuser: false,
|
||||
isDefaultPassword: false,
|
||||
settings,
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
permissions: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSeriesCollapsePreference', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockApi.mockResolvedValue(new Response(null, { status: 204 }))
|
||||
})
|
||||
|
||||
describe('getEffectivePreference', () => {
|
||||
it('returns false when user has no settings', () => {
|
||||
const user = ref<AuthUser | null>(makeUser({}))
|
||||
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({})).toBe(false)
|
||||
expect(getEffectivePreference({ libraryId: 1 })).toBe(false)
|
||||
expect(getEffectivePreference({ collectionId: 5 })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when user is null', () => {
|
||||
mockUseAuth.mockReturnValue({ user: ref(null) } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns global preference when no library or collection context', () => {
|
||||
const user = ref(
|
||||
makeUser({
|
||||
seriesCollapsePreferences: { global: true, libraries: {}, collections: {} },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns library override over global when libraryId matches', () => {
|
||||
const user = ref(
|
||||
makeUser({
|
||||
seriesCollapsePreferences: { global: true, libraries: { '3': false }, collections: {} },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({ libraryId: 3 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to global when library has no override', () => {
|
||||
const user = ref(
|
||||
makeUser({
|
||||
seriesCollapsePreferences: { global: true, libraries: { '3': false }, collections: {} },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({ libraryId: 99 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns collection override over library override', () => {
|
||||
const user = ref(
|
||||
makeUser({
|
||||
seriesCollapsePreferences: { global: false, libraries: { '1': true }, collections: { '5': false } },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { getEffectivePreference } = useSeriesCollapsePreference()
|
||||
|
||||
expect(getEffectivePreference({ collectionId: 5, libraryId: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPreference', () => {
|
||||
it('sets global preference optimistically', async () => {
|
||||
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {} } }))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(prefs.value!.global).toBe(true)
|
||||
})
|
||||
|
||||
it('calls the API with global body', async () => {
|
||||
const userRef = ref(makeUser({}))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
'/api/v1/users/me/series-collapse-preferences',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ global: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets a library override optimistically', async () => {
|
||||
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {} } }))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ libraryId: 7 }, true)
|
||||
|
||||
expect(prefs.value!.libraries['7']).toBe(true)
|
||||
})
|
||||
|
||||
it('calls the API with library body', async () => {
|
||||
const userRef = ref(makeUser({}))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ libraryId: 7 }, false)
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
'/api/v1/users/me/series-collapse-preferences',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ libraries: { '7': false } }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets a collection override optimistically', async () => {
|
||||
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {} } }))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ collectionId: 9 }, true)
|
||||
|
||||
expect(prefs.value!.collections['9']).toBe(true)
|
||||
})
|
||||
|
||||
it('calls the API with collection body', async () => {
|
||||
const userRef = ref(makeUser({}))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference({ collectionId: 9 }, true)
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(
|
||||
'/api/v1/users/me/series-collapse-preferences',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ collections: { '9': true } }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('initialises preferences from defaults when user has none', async () => {
|
||||
const userRef = ref(makeUser({}))
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {} })
|
||||
})
|
||||
|
||||
it('preserves existing library overrides when setting global', async () => {
|
||||
const userRef = ref(
|
||||
makeUser({
|
||||
seriesCollapsePreferences: { global: false, libraries: { '2': true }, collections: {} },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference, prefs } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(prefs.value!.libraries['2']).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mutate user settings when user is null', async () => {
|
||||
mockUseAuth.mockReturnValue({ user: ref(null) } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference } = useSeriesCollapsePreference()
|
||||
|
||||
await expect(setPreference('global', true)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reverts optimistic update when API returns non-ok response', async () => {
|
||||
mockApi.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
const initialSettings = { someOtherKey: 'preserved' }
|
||||
const userRef = ref(
|
||||
makeUser({
|
||||
...initialSettings,
|
||||
seriesCollapsePreferences: { global: false, libraries: {}, collections: {} },
|
||||
}),
|
||||
)
|
||||
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
|
||||
|
||||
const { setPreference } = useSeriesCollapsePreference()
|
||||
|
||||
await setPreference('global', true)
|
||||
|
||||
expect(userRef.value!.settings).toMatchObject({ seriesCollapsePreferences: { global: false } })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed } from 'vue'
|
||||
import type { SeriesCollapsePreferences } from '@projectx/types'
|
||||
import { resolveCollapsePreference } from '@projectx/types'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useSeriesCollapsePreference() {
|
||||
const { user } = useAuth()
|
||||
|
||||
const prefs = computed((): SeriesCollapsePreferences | undefined => {
|
||||
return user.value?.settings.seriesCollapsePreferences
|
||||
})
|
||||
|
||||
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number }): boolean {
|
||||
return resolveCollapsePreference(prefs.value, ctx)
|
||||
}
|
||||
|
||||
async function setPreference(ctx: { libraryId?: number; collectionId?: number } | 'global', value: boolean): Promise<void> {
|
||||
let body: Record<string, unknown>
|
||||
if (ctx === 'global') {
|
||||
body = { global: value }
|
||||
} else if (ctx.collectionId !== undefined) {
|
||||
body = { collections: { [String(ctx.collectionId)]: value } }
|
||||
} else if (ctx.libraryId !== undefined) {
|
||||
body = { libraries: { [String(ctx.libraryId)]: value } }
|
||||
} else {
|
||||
body = { global: value }
|
||||
}
|
||||
|
||||
const current = prefs.value ?? { global: false, libraries: {}, collections: {} }
|
||||
const updated: SeriesCollapsePreferences = {
|
||||
global: body.global !== undefined ? (body.global as boolean) : (current.global ?? false),
|
||||
libraries: { ...current.libraries, ...(body.libraries as Record<string, boolean>) },
|
||||
collections: { ...current.collections, ...(body.collections as Record<string, boolean>) },
|
||||
}
|
||||
|
||||
const previous = user.value?.settings ? { ...user.value.settings } : undefined
|
||||
|
||||
if (user.value) {
|
||||
user.value.settings = {
|
||||
...user.value.settings,
|
||||
seriesCollapsePreferences: updated,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/users/me/series-collapse-preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok && user.value && previous !== undefined) {
|
||||
user.value.settings = previous
|
||||
}
|
||||
} catch {
|
||||
if (user.value && previous !== undefined) {
|
||||
user.value.settings = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { getEffectivePreference, setPreference, prefs }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { BookCard } from '@projectx/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useCollectionBooks(collectionId: Ref<number>) {
|
||||
export function useCollectionBooks(collectionId: Ref<number>, collapseEnabled: Ref<boolean> = ref(false)) {
|
||||
const items = ref<BookCard[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
@@ -21,7 +21,9 @@ export function useCollectionBooks(collectionId: Ref<number>) {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/collections/${collectionId.value}/books?page=${page}&size=${PAGE_SIZE}`)
|
||||
const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE) })
|
||||
if (collapseEnabled.value) params.set('collapseSeries', 'true')
|
||||
const res = await api(`/api/v1/collections/${collectionId.value}/books?${params.toString()}`)
|
||||
if (!res.ok) throw new Error('Failed to load collection books')
|
||||
const data = await res.json()
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
|
||||
@@ -462,9 +462,9 @@ watch(seriesGridGap, (value) => {
|
||||
|
||||
<!-- Grid -->
|
||||
<div
|
||||
class="grid"
|
||||
class="series-grid grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${seriesCardWidth}px, 1fr))`,
|
||||
'--series-card-width': `${seriesCardWidth}px`,
|
||||
gap: `${seriesGridGap}px`,
|
||||
}"
|
||||
>
|
||||
@@ -495,3 +495,15 @@ watch(seriesGridGap, (value) => {
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div ref="sentinel" class="h-px" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.series-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--series-card-width), 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.series-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDisplaySettings, type CardOverlayKey, type CoverSizeScope } from '@/
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const {
|
||||
@@ -20,6 +21,13 @@ const {
|
||||
authorCoverShape,
|
||||
} = useDisplaySettings()
|
||||
|
||||
const { prefs, setPreference } = useSeriesCollapsePreference()
|
||||
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
|
||||
|
||||
async function handleGlobalCollapseToggle(value: boolean) {
|
||||
await setPreference('global', value)
|
||||
}
|
||||
|
||||
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
|
||||
{ key: 'progress-bar', label: 'Progress bar', hint: 'Thin colored line along the bottom edge' },
|
||||
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
|
||||
@@ -263,7 +271,7 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
<p class="settings-label">Portrait cover size</p>
|
||||
<p class="settings-hint">Used for portrait libraries and views</p>
|
||||
</div>
|
||||
<div class="w-full md:w-56">
|
||||
<div class="w-full md:w-72">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">Cover size</span>
|
||||
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitCoverSize }}px</span>
|
||||
@@ -290,7 +298,7 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
<p class="settings-label">Square cover size</p>
|
||||
<p class="settings-hint">Used for square libraries and views</p>
|
||||
</div>
|
||||
<div class="w-full md:w-56">
|
||||
<div class="w-full md:w-72">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">Cover size</span>
|
||||
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareCoverSize }}px</span>
|
||||
@@ -317,7 +325,7 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
<p class="settings-label">Portrait grid spacing</p>
|
||||
<p class="settings-hint">Gap between portrait covers</p>
|
||||
</div>
|
||||
<div class="w-full md:w-56">
|
||||
<div class="w-full md:w-72">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">Grid spacing</span>
|
||||
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitGridGap }}px</span>
|
||||
@@ -344,7 +352,7 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
<p class="settings-label">Square grid spacing</p>
|
||||
<p class="settings-hint">Gap between square covers</p>
|
||||
</div>
|
||||
<div class="w-full md:w-56">
|
||||
<div class="w-full md:w-72">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">Grid spacing</span>
|
||||
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareGridGap }}px</span>
|
||||
@@ -372,7 +380,7 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
<p class="settings-label">Cover size</p>
|
||||
<p class="settings-hint">Width of author covers in the grid</p>
|
||||
</div>
|
||||
<div class="w-full md:w-56">
|
||||
<div class="w-full md:w-72">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-muted-foreground">Cover size</span>
|
||||
<span class="text-xs font-medium tabular-nums text-foreground">{{ authorCoverSize }}px</span>
|
||||
@@ -442,4 +450,20 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series -->
|
||||
<div class="mt-8">
|
||||
<p class="settings-group-label">Series</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Collapse series by default</p>
|
||||
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
|
||||
Group books in the same series into a single card in library and collection views
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="globalCollapseEnabled" @update:model-value="handleGlobalCollapseToggle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FolderOpen, Pencil, SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import { CheckSquare, FolderOpen, Layers, Pencil, SlidersHorizontal, Square, X } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
@@ -20,6 +21,7 @@ import { useBookNavigation } from '@/features/book/composables/useBookNavigation
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
@@ -43,7 +45,18 @@ const pageTitle = computed(() => {
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
const { items: books, total, loading, initialized: booksInitialized, hasMore, load } = useCollectionBooks(collectionId)
|
||||
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
|
||||
const collapseEnabledRef = ref(getEffectivePreference({ collectionId: collectionId.value }))
|
||||
|
||||
watch(collectionId, (id) => {
|
||||
collapseEnabledRef.value = getEffectivePreference({ collectionId: id })
|
||||
})
|
||||
|
||||
watch(prefs, () => {
|
||||
collapseEnabledRef.value = getEffectivePreference({ collectionId: collectionId.value })
|
||||
})
|
||||
|
||||
const { items: books, total, loading, initialized: booksInitialized, hasMore, load } = useCollectionBooks(collectionId, collapseEnabledRef)
|
||||
const { setBookContext, registerLoadMore } = useBookNavigation()
|
||||
watch(
|
||||
[books, total],
|
||||
@@ -191,6 +204,13 @@ function handleBookAction(book: BookCard, action: 'quick-view' | 'edit-metadata'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
const next = !collapseEnabledRef.value
|
||||
collapseEnabledRef.value = next
|
||||
load(true)
|
||||
await setPreference({ collectionId: collectionId.value }, next)
|
||||
}
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
function checkSentinel() {
|
||||
@@ -290,6 +310,23 @@ watch(
|
||||
@toggle-selection="toggleSelectionMode"
|
||||
>
|
||||
<template #toolbar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ collapseEnabledRef ? 'Expand series' : 'Collapse series' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
@@ -310,10 +347,29 @@ watch(
|
||||
<SlidersHorizontal :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
<template #mobile-menu>
|
||||
<DropdownMenuItem @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
|
||||
<Square v-else :size="14" class="mr-2" />
|
||||
Collapse series
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</ViewHeader>
|
||||
|
||||
<section v-if="mobileControlsExpanded" class="mb-3 rounded-lg border border-border/70 bg-card/70 p-2 sm:hidden">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="13" />
|
||||
<span>{{ collapseEnabledRef ? 'Expanded' : 'Collapse series' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="collection"
|
||||
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, Filter, SlidersHorizontal, Telescope, X } from 'lucide-vue-next'
|
||||
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, CheckSquare, Filter, Layers, SlidersHorizontal, Square, Telescope, X } from 'lucide-vue-next'
|
||||
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
@@ -16,6 +17,7 @@ import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
import SaveAsLensDialog from '@/features/lens/components/SaveAsLensDialog.vue'
|
||||
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
|
||||
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
|
||||
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
@@ -57,7 +59,29 @@ usePageTitle(pageTitle)
|
||||
|
||||
provide(COVER_ASPECT_RATIO_KEY, currentCoverAspectRatio)
|
||||
|
||||
const { items: books, total, loading, initialized: booksInitialized, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
|
||||
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
|
||||
const collapseEnabledRef = ref(libraryId.value !== null ? getEffectivePreference({ libraryId: libraryId.value }) : false)
|
||||
|
||||
watch(libraryId, (id) => {
|
||||
collapseEnabledRef.value = id !== null ? getEffectivePreference({ libraryId: id }) : false
|
||||
})
|
||||
|
||||
watch(prefs, () => {
|
||||
collapseEnabledRef.value = libraryId.value !== null ? getEffectivePreference({ libraryId: libraryId.value }) : false
|
||||
})
|
||||
|
||||
const {
|
||||
items: books,
|
||||
total,
|
||||
loading,
|
||||
initialized: booksInitialized,
|
||||
error,
|
||||
filter,
|
||||
sort,
|
||||
hasMore,
|
||||
load,
|
||||
clear,
|
||||
} = useBookQuery(libraryId, collapseEnabledRef)
|
||||
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
|
||||
const { setBookContext, registerLoadMore } = useBookNavigation()
|
||||
@@ -359,6 +383,14 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
promptDelete(book.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleCollapse() {
|
||||
if (libraryId.value === null) return
|
||||
const next = !collapseEnabledRef.value
|
||||
collapseEnabledRef.value = next
|
||||
load(true)
|
||||
await setPreference({ libraryId: libraryId.value }, next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -410,6 +442,22 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="hidden sm:block w-px h-5 bg-border shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
|
||||
:class="
|
||||
collapseEnabledRef
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
@click="handleToggleCollapse"
|
||||
>
|
||||
<Layers :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ collapseEnabledRef ? 'Expand series' : 'Collapse series' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
@click="toggleFilterPanel"
|
||||
class="hidden sm:flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
|
||||
@@ -455,6 +503,13 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #mobile-menu>
|
||||
<DropdownMenuItem @click="handleToggleCollapse">
|
||||
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
|
||||
<Square v-else :size="14" class="mr-2" />
|
||||
Collapse series
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</ViewHeader>
|
||||
|
||||
<section v-if="mobileControlsExpanded" class="mb-3 space-y-2 rounded-lg border border-border/70 bg-card/70 p-2 sm:hidden">
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UserSettings {
|
||||
tourCompleted?: boolean;
|
||||
};
|
||||
notificationPreferences?: import("./notification").NotificationPreferences;
|
||||
seriesCollapsePreferences?: import("./series-collapse").SeriesCollapsePreferences;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type BookCard = {
|
||||
addedAt: string;
|
||||
hasCover: boolean;
|
||||
hasMetadataLocks: boolean;
|
||||
collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo;
|
||||
};
|
||||
|
||||
export type BookDetailFile = {
|
||||
|
||||
@@ -28,3 +28,4 @@ export * from "./user-statistics";
|
||||
export * from "./migration";
|
||||
export * from "./notification";
|
||||
export * from "./series";
|
||||
export * from "./series-collapse";
|
||||
|
||||
@@ -114,9 +114,40 @@ export type GroupRule = {
|
||||
rules: (Rule | GroupRule)[];
|
||||
};
|
||||
|
||||
export type SortField = "author" | "title" | "series" | "seriesIndex" | "addedAt" | "updatedAt" | "publishedYear" | "pageCount" | "rating" | "publisher" | "fileSize" | "readProgress" | "lastReadAt" | "finishedAt" | "random";
|
||||
export type SortField =
|
||||
| "author"
|
||||
| "title"
|
||||
| "series"
|
||||
| "seriesIndex"
|
||||
| "addedAt"
|
||||
| "updatedAt"
|
||||
| "publishedYear"
|
||||
| "pageCount"
|
||||
| "rating"
|
||||
| "publisher"
|
||||
| "fileSize"
|
||||
| "readProgress"
|
||||
| "lastReadAt"
|
||||
| "finishedAt"
|
||||
| "random";
|
||||
|
||||
export const SORT_FIELDS: SortField[] = ["author", "title", "series", "seriesIndex", "addedAt", "updatedAt", "publishedYear", "pageCount", "rating", "publisher", "fileSize", "readProgress", "lastReadAt", "finishedAt", "random"];
|
||||
export const SORT_FIELDS: SortField[] = [
|
||||
"author",
|
||||
"title",
|
||||
"series",
|
||||
"seriesIndex",
|
||||
"addedAt",
|
||||
"updatedAt",
|
||||
"publishedYear",
|
||||
"pageCount",
|
||||
"rating",
|
||||
"publisher",
|
||||
"fileSize",
|
||||
"readProgress",
|
||||
"lastReadAt",
|
||||
"finishedAt",
|
||||
"random",
|
||||
];
|
||||
|
||||
export type SortSpec = {
|
||||
field: SortField;
|
||||
@@ -127,4 +158,5 @@ export type BookQuery = {
|
||||
filter?: GroupRule;
|
||||
sort: SortSpec[];
|
||||
pagination: { page: number; size: number };
|
||||
collapseSeries?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export type SeriesCollapsePreferences = {
|
||||
global: boolean;
|
||||
libraries: Record<string, boolean>;
|
||||
collections: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type CollapsedSeriesInfo = {
|
||||
bookCount: number;
|
||||
readCount: number;
|
||||
coverBookIds: number[];
|
||||
seriesLatestAddedAt: string | null;
|
||||
};
|
||||
|
||||
export function resolveCollapsePreference(prefs: SeriesCollapsePreferences | undefined, ctx: { libraryId?: number; collectionId?: number }): boolean {
|
||||
if (!prefs) return false;
|
||||
if (ctx.collectionId !== undefined) {
|
||||
const override = prefs.collections?.[String(ctx.collectionId)];
|
||||
if (override !== undefined) return override;
|
||||
}
|
||||
if (ctx.libraryId !== undefined) {
|
||||
const override = prefs.libraries?.[String(ctx.libraryId)];
|
||||
if (override !== undefined) return override;
|
||||
}
|
||||
return prefs.global ?? false;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Res,
|
||||
Sse,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
@@ -201,6 +202,7 @@ export class AuthorsController {
|
||||
return this.authorsService.findBooks(user, id, dto);
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@Get(':id/thumbnail')
|
||||
async getThumbnail(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
|
||||
const thumbnailPath = await this.authorsService.getThumbnailPath(user, id);
|
||||
@@ -216,6 +218,7 @@ export class AuthorsController {
|
||||
reply.send(createReadStream(thumbnailPath));
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@Get(':id/image')
|
||||
async getImage(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
|
||||
const imagePath = await this.authorsService.getImagePath(user, id);
|
||||
|
||||
@@ -821,3 +821,143 @@ describe('coverRuleToSql', () => {
|
||||
expect(getRuleSql(where)).toMatchObject({ type: 'isNotNull' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookQueryBuilder.hasSeriesFilter', () => {
|
||||
it('returns false for undefined', () => {
|
||||
expect(BookQueryBuilder.hasSeriesFilter(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a group with no rules', () => {
|
||||
expect(BookQueryBuilder.hasSeriesFilter({ type: 'group', join: 'AND', rules: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no series rule is present', () => {
|
||||
const node = {
|
||||
type: 'group' as const,
|
||||
join: 'AND' as const,
|
||||
rules: [
|
||||
{ type: 'rule' as const, field: 'title' as never, operator: 'contains' as never, value: 'Dune' },
|
||||
{ type: 'rule' as const, field: 'author' as never, operator: 'contains' as never, value: 'Frank' },
|
||||
],
|
||||
};
|
||||
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a direct series rule', () => {
|
||||
const node = { type: 'rule' as const, field: 'series' as never, operator: 'contains' as never, value: 'Dune' };
|
||||
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when series rule is inside a nested group', () => {
|
||||
const inner = {
|
||||
type: 'group' as const,
|
||||
join: 'OR' as const,
|
||||
rules: [{ type: 'rule' as const, field: 'series' as never, operator: 'equals' as never, value: 'Mistborn' }],
|
||||
};
|
||||
const outer = { type: 'group' as const, join: 'AND' as const, rules: [inner] };
|
||||
expect(BookQueryBuilder.hasSeriesFilter(outer)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when series rule is alongside other rules in a group', () => {
|
||||
const node = {
|
||||
type: 'group' as const,
|
||||
join: 'AND' as const,
|
||||
rules: [
|
||||
{ type: 'rule' as const, field: 'title' as never, operator: 'contains' as never, value: 'book' },
|
||||
{ type: 'rule' as const, field: 'series' as never, operator: 'equals' as never, value: 'Stormlight' },
|
||||
],
|
||||
};
|
||||
expect(BookQueryBuilder.hasSeriesFilter(node)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookQueryBuilder.buildCollapseOrderBy', () => {
|
||||
it('returns default sort when sort array is empty', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([], 1)).toBe('sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('returns default sort when all directions are invalid', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'title', dir: 'invalid' as never }], 1);
|
||||
expect(result).toBe('sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates sort_title for title field', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'title', dir: 'asc' }], 1)).toBe('sort_title ASC NULLS LAST');
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'title', dir: 'desc' }], 1)).toBe('sort_title DESC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates sort_title for series field', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'series', dir: 'asc' }], 1)).toBe('sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates sort_added_at for addedAt field', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'addedAt', dir: 'desc' }], 1)).toBe('sort_added_at DESC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates seriesIndex with sort_title fallback when series is not in sort', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'seriesIndex', dir: 'asc' }], 1);
|
||||
expect(result).toBe('series_index ASC NULLS LAST, sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('does not add sort_title fallback when series field is already in sort', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy(
|
||||
[
|
||||
{ field: 'seriesIndex', dir: 'asc' },
|
||||
{ field: 'series', dir: 'asc' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
expect(result).toBe('series_index ASC NULLS LAST, sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates user-scoped subquery for readProgress', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'readProgress', dir: 'desc' }], 42);
|
||||
expect(result).toContain('rp.user_id = 42');
|
||||
expect(result).toContain('bf.book_id = r.id');
|
||||
expect(result).toContain('DESC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates user-scoped subquery for lastReadAt', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'lastReadAt', dir: 'asc' }], 7);
|
||||
expect(result).toContain('rp.user_id = 7');
|
||||
expect(result).toContain('bf.book_id = r.id');
|
||||
expect(result).toContain('ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates user-scoped subquery for finishedAt', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'finishedAt', dir: 'desc' }], 5);
|
||||
expect(result).toContain('ubs.user_id = 5');
|
||||
expect(result).toContain('ubs.book_id = r.id');
|
||||
expect(result).toContain('DESC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates author sort using r.id to avoid column shadowing', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'author', dir: 'asc' }], 1);
|
||||
expect(result).toContain('ba.book_id = r.id');
|
||||
expect(result).toContain('ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates publishedYear sort', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'publishedYear', dir: 'asc' }], 1)).toBe('published_year ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('generates rating sort', () => {
|
||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'rating', dir: 'desc' }], 1)).toBe('rating DESC NULLS LAST');
|
||||
});
|
||||
|
||||
it('joins multiple sort parts with comma', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy(
|
||||
[
|
||||
{ field: 'addedAt', dir: 'desc' },
|
||||
{ field: 'title', dir: 'asc' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
expect(result).toBe('sort_added_at DESC NULLS LAST, sort_title ASC NULLS LAST');
|
||||
});
|
||||
|
||||
it('skips unrecognised sort fields silently', () => {
|
||||
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'unknownField' as never, dir: 'asc' }], 1);
|
||||
expect(result).toBe('sort_title ASC NULLS LAST');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,6 +511,80 @@ export class BookQueryBuilder {
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for isbn field`);
|
||||
}
|
||||
}
|
||||
|
||||
static hasSeriesFilter(node: GroupRule | Rule | undefined): boolean {
|
||||
if (!node) return false;
|
||||
if (node.type === 'rule') return node.field === 'series';
|
||||
return node.rules.some((r) => BookQueryBuilder.hasSeriesFilter(r));
|
||||
}
|
||||
|
||||
static buildCollapseOrderBy(sort: SortSpec[], userId: number): string {
|
||||
if (sort.length === 0) return 'sort_title ASC NULLS LAST';
|
||||
const parts: string[] = [];
|
||||
for (const { field, dir } of sort) {
|
||||
const D = dir.toUpperCase();
|
||||
if (D !== 'ASC' && D !== 'DESC') continue;
|
||||
switch (field) {
|
||||
case 'title':
|
||||
case 'series':
|
||||
parts.push(`sort_title ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'addedAt':
|
||||
parts.push(`sort_added_at ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'seriesIndex':
|
||||
parts.push(`series_index ${D} NULLS LAST`);
|
||||
if (!sort.some((s) => s.field === 'series')) {
|
||||
parts.push(`sort_title ${D} NULLS LAST`);
|
||||
}
|
||||
break;
|
||||
case 'publishedYear':
|
||||
parts.push(`published_year ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'rating':
|
||||
parts.push(`rating ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'publisher':
|
||||
parts.push(`publisher ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'pageCount':
|
||||
parts.push(`page_count ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
parts.push(`updated_at ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'author':
|
||||
parts.push(
|
||||
`(SELECT a.sort_name FROM book_authors ba INNER JOIN authors a ON ba.author_id = a.id WHERE ba.book_id = r.id ORDER BY ba.display_order LIMIT 1) ${D} NULLS LAST`,
|
||||
);
|
||||
break;
|
||||
case 'fileSize':
|
||||
parts.push(`(SELECT bf.size_bytes FROM book_files bf WHERE bf.id = r.primary_file_id) ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'readProgress':
|
||||
parts.push(
|
||||
`(SELECT max(rp.percentage) FROM reading_progress rp INNER JOIN book_files bf ON rp.book_file_id = bf.id WHERE bf.book_id = r.id AND rp.user_id = ${userId}) ${D} NULLS LAST`,
|
||||
);
|
||||
break;
|
||||
case 'lastReadAt':
|
||||
parts.push(
|
||||
`(SELECT max(rp.updated_at) FROM reading_progress rp INNER JOIN book_files bf ON rp.book_file_id = bf.id WHERE bf.book_id = r.id AND rp.user_id = ${userId}) ${D} NULLS LAST`,
|
||||
);
|
||||
break;
|
||||
case 'finishedAt':
|
||||
parts.push(`(SELECT ubs.finished_at FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${userId}) ${D} NULLS LAST`);
|
||||
break;
|
||||
case 'random': {
|
||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
||||
const scopedSeed = daySeed + userId;
|
||||
parts.push(`md5(r.id::text || ':' || ${scopedSeed}::text) ${D}`);
|
||||
parts.push(`r.id ${D}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(', ') : 'sort_title ASC NULLS LAST';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLikePattern(s: string): string {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('BookController', () => {
|
||||
await controller.getCover(7, makeUser(), reply, '"1234"');
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(304);
|
||||
expect(headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(headers['Cache-Control']).toBe('no-cache');
|
||||
expect(headers['ETag']).toBe('"1234"');
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
expect(mockCreateReadStream).not.toHaveBeenCalled();
|
||||
@@ -195,7 +195,7 @@ describe('BookController', () => {
|
||||
|
||||
await controller.getCover(7, makeUser(), reply, undefined);
|
||||
|
||||
expect(headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(headers['Cache-Control']).toBe('no-cache');
|
||||
expect(headers['ETag']).toBe('"4321"');
|
||||
expect(reply.type).toHaveBeenCalledWith('image/png');
|
||||
expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/cover.png');
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import archiver from 'archiver';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
@@ -285,6 +286,7 @@ export class BookController {
|
||||
}
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@Get(':id/cover')
|
||||
async getCover(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@@ -298,17 +300,18 @@ export class BookController {
|
||||
const { mtimeMs } = await stat(coverPath);
|
||||
const etag = `"${Math.floor(mtimeMs)}"`;
|
||||
if (ifNoneMatch === etag) {
|
||||
reply.status(304).header('Cache-Control', 'private, max-age=86400').header('ETag', etag).send();
|
||||
reply.status(304).header('Cache-Control', 'no-cache').header('ETag', etag).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = imageContentTypeFromPath(coverPath);
|
||||
reply.header('Cache-Control', 'private, max-age=86400');
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('ETag', etag);
|
||||
reply.type(contentType);
|
||||
reply.send(createReadStream(coverPath));
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@Get(':id/thumbnail')
|
||||
async getThumbnail(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@@ -322,11 +325,11 @@ export class BookController {
|
||||
const { mtimeMs } = await stat(thumbnailPath);
|
||||
const etag = `"${Math.floor(mtimeMs)}"`;
|
||||
if (ifNoneMatch === etag) {
|
||||
reply.status(304).header('Cache-Control', 'private, max-age=86400').header('ETag', etag).send();
|
||||
reply.status(304).header('Cache-Control', 'no-cache').header('ETag', etag).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'private, max-age=86400');
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('ETag', etag);
|
||||
reply.type('image/jpeg');
|
||||
reply.send(createReadStream(thumbnailPath));
|
||||
|
||||
@@ -3,6 +3,8 @@ import { SQL, and, asc, count, eq, inArray, isNotNull, or, sql } from 'drizzle-o
|
||||
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { SortSpec } from '@projectx/types';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import {
|
||||
@@ -32,6 +34,28 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
type MetadataUpdateExecutor = Pick<Db, 'update'>;
|
||||
type MetadataReadExecutor = Pick<Db, 'select'>;
|
||||
|
||||
type CollapsedRawRow = {
|
||||
id: number;
|
||||
status: string;
|
||||
primary_file_id: number | null;
|
||||
folder_path: string;
|
||||
added_at: string;
|
||||
title: string | null;
|
||||
series_name: string | null;
|
||||
series_index: number | null;
|
||||
published_year: number | null;
|
||||
language: string | null;
|
||||
rating: number | null;
|
||||
cover_source: string | null;
|
||||
locked_fields: string[] | null;
|
||||
sort_title: string | null;
|
||||
sort_added_at: string | null;
|
||||
book_count: string | null;
|
||||
read_count: string | null;
|
||||
cover_book_ids: number[] | null;
|
||||
total_count: string;
|
||||
};
|
||||
type PatternMetadataRow = {
|
||||
bookId: number;
|
||||
title: string | null;
|
||||
@@ -156,6 +180,202 @@ export class BookRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findCardsCollapsed(opts: { where: SQL | undefined; sort: SortSpec[]; limit: number; offset: number; userId: number }): Promise<{
|
||||
rows: Array<{
|
||||
id: number;
|
||||
status: string;
|
||||
primaryFileId: number | null;
|
||||
folderPath: string;
|
||||
addedAt: Date;
|
||||
title: string | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
publishedYear: number | null;
|
||||
language: string | null;
|
||||
rating: number | null;
|
||||
coverSource: string | null;
|
||||
lockedFields: string[] | null;
|
||||
bookCount: number | null;
|
||||
readCount: number | null;
|
||||
coverBookIds: number[] | null;
|
||||
seriesLatestAddedAt: Date | null;
|
||||
}>;
|
||||
authorRows: { bookId: number; name: string }[];
|
||||
fileRows: { bookId: number; id: number; format: string | null; role: string }[];
|
||||
genreRows: { bookId: number; name: string }[];
|
||||
progressRows: { bookFileId: number; percentage: number | null }[];
|
||||
statusRows: {
|
||||
bookId: number;
|
||||
status: string;
|
||||
source: string;
|
||||
startedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
total: number;
|
||||
}> {
|
||||
const { where, sort, limit, offset, userId } = opts;
|
||||
const whereFragment = where ?? sql`1=1`;
|
||||
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId);
|
||||
|
||||
const result = await this.db.execute<CollapsedRawRow>(sql`
|
||||
WITH series_agg AS (
|
||||
SELECT
|
||||
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series,
|
||||
books.library_id,
|
||||
COUNT(*) AS book_count,
|
||||
SUM(CASE WHEN user_book_status.status = 'read' THEN 1 ELSE 0 END) AS read_count,
|
||||
MAX(books.added_at) AS latest_added_at
|
||||
FROM books
|
||||
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
|
||||
LEFT JOIN user_book_status ON user_book_status.book_id = books.id AND user_book_status.user_id = ${userId}
|
||||
WHERE ${whereFragment}
|
||||
AND NULLIF(lower(btrim(book_metadata.series_name)), '') IS NOT NULL
|
||||
GROUP BY NULLIF(lower(btrim(book_metadata.series_name)), ''), books.library_id
|
||||
),
|
||||
series_covers AS (
|
||||
SELECT
|
||||
sa.norm_series,
|
||||
sa.library_id,
|
||||
covers.cover_book_ids
|
||||
FROM series_agg sa
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT COALESCE(
|
||||
ARRAY_AGG(sub.id ORDER BY sub.series_index ASC NULLS LAST, sub.added_at ASC),
|
||||
ARRAY[]::int[]
|
||||
) AS cover_book_ids
|
||||
FROM (
|
||||
SELECT b2.id, bm2.series_index, b2.added_at
|
||||
FROM books b2
|
||||
JOIN book_metadata bm2 ON bm2.book_id = b2.id
|
||||
WHERE NULLIF(lower(btrim(bm2.series_name)), '') = sa.norm_series
|
||||
AND b2.library_id = sa.library_id
|
||||
ORDER BY bm2.series_index ASC NULLS LAST, b2.added_at ASC
|
||||
LIMIT 4
|
||||
) sub
|
||||
) covers
|
||||
),
|
||||
representatives AS (
|
||||
SELECT DISTINCT ON (books.library_id, COALESCE(NULLIF(lower(btrim(book_metadata.series_name)), ''), 'book_' || books.id::text))
|
||||
books.id,
|
||||
books.status,
|
||||
books.primary_file_id,
|
||||
books.folder_path,
|
||||
books.added_at,
|
||||
books.updated_at,
|
||||
book_metadata.title,
|
||||
book_metadata.series_name,
|
||||
book_metadata.series_index,
|
||||
book_metadata.published_year,
|
||||
book_metadata.language,
|
||||
book_metadata.rating,
|
||||
book_metadata.cover_source,
|
||||
book_metadata.locked_fields,
|
||||
book_metadata.publisher,
|
||||
book_metadata.page_count,
|
||||
COALESCE(NULLIF(lower(btrim(book_metadata.series_name)), ''), lower(book_metadata.title)) AS sort_title,
|
||||
COALESCE(sa.latest_added_at, books.added_at) AS sort_added_at,
|
||||
sa.book_count,
|
||||
sa.read_count,
|
||||
sc.cover_book_ids
|
||||
FROM books
|
||||
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
|
||||
LEFT JOIN series_agg sa
|
||||
ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '')
|
||||
AND sa.library_id = books.library_id
|
||||
LEFT JOIN series_covers sc
|
||||
ON sc.norm_series = sa.norm_series
|
||||
AND sc.library_id = sa.library_id
|
||||
WHERE ${whereFragment}
|
||||
ORDER BY
|
||||
books.library_id,
|
||||
COALESCE(NULLIF(lower(btrim(book_metadata.series_name)), ''), 'book_' || books.id::text),
|
||||
book_metadata.series_index ASC NULLS LAST,
|
||||
books.added_at ASC,
|
||||
books.id ASC
|
||||
)
|
||||
SELECT r.*,
|
||||
COUNT(*) OVER () AS total_count
|
||||
FROM representatives r
|
||||
ORDER BY ${sql.raw(orderBy)}
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`);
|
||||
|
||||
const rawRows = result.rows as CollapsedRawRow[];
|
||||
const total = rawRows.length > 0 ? Number(rawRows[0].total_count) : 0;
|
||||
|
||||
const mappedRows = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
primaryFileId: r.primary_file_id,
|
||||
folderPath: r.folder_path,
|
||||
addedAt: new Date(r.added_at),
|
||||
title: r.title,
|
||||
seriesName: r.series_name,
|
||||
seriesIndex: r.series_index,
|
||||
publishedYear: r.published_year,
|
||||
language: r.language,
|
||||
rating: r.rating,
|
||||
coverSource: r.cover_source,
|
||||
lockedFields: r.locked_fields,
|
||||
bookCount: r.book_count !== null ? Number(r.book_count) : null,
|
||||
readCount: r.read_count !== null ? Number(r.read_count) : null,
|
||||
coverBookIds: r.cover_book_ids,
|
||||
seriesLatestAddedAt: r.sort_added_at ? new Date(r.sort_added_at) : null,
|
||||
}));
|
||||
|
||||
const bookIds = mappedRows.map((r) => r.id);
|
||||
|
||||
const [authorRows, fileRows, genreRows] = await Promise.all([
|
||||
bookIds.length > 0
|
||||
? this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, bookIds))
|
||||
.orderBy(bookAuthors.displayOrder)
|
||||
: [],
|
||||
bookIds.length > 0
|
||||
? this.db
|
||||
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
|
||||
.from(bookFiles)
|
||||
.where(inArray(bookFiles.bookId, bookIds))
|
||||
: ([] as { bookId: number; id: number; format: string | null; role: string }[]),
|
||||
bookIds.length > 0
|
||||
? this.db
|
||||
.select({ bookId: bookGenres.bookId, name: genres.name })
|
||||
.from(bookGenres)
|
||||
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
|
||||
.where(inArray(bookGenres.bookId, bookIds))
|
||||
: [],
|
||||
]);
|
||||
|
||||
const primaryFileIds = mappedRows.map((r) => r.primaryFileId).filter((id): id is number => id != null);
|
||||
const [progressRows, statusRows] = await Promise.all([
|
||||
primaryFileIds.length > 0
|
||||
? this.db
|
||||
.select({ bookFileId: readingProgress.bookFileId, percentage: readingProgress.percentage })
|
||||
.from(readingProgress)
|
||||
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
|
||||
: Promise.resolve([]),
|
||||
bookIds.length > 0
|
||||
? this.db
|
||||
.select({
|
||||
bookId: userBookStatus.bookId,
|
||||
status: userBookStatus.status,
|
||||
source: userBookStatus.source,
|
||||
startedAt: userBookStatus.startedAt,
|
||||
finishedAt: userBookStatus.finishedAt,
|
||||
updatedAt: userBookStatus.updatedAt,
|
||||
})
|
||||
.from(userBookStatus)
|
||||
.where(and(eq(userBookStatus.userId, userId), inArray(userBookStatus.bookId, bookIds)))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
return { rows: mappedRows, authorRows, fileRows, genreRows, progressRows, statusRows, total };
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
const [book] = await this.db
|
||||
.select()
|
||||
|
||||
@@ -101,6 +101,7 @@ function makeService() {
|
||||
withTransaction: vi.fn(),
|
||||
deleteByIds: vi.fn(),
|
||||
findAllIds: vi.fn(),
|
||||
findCardsCollapsed: vi.fn(),
|
||||
};
|
||||
const libraryService = {
|
||||
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -1360,6 +1361,174 @@ describe('BookService', () => {
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('queryForLibrary routes to findCardsCollapsed when collapseSeries is true', async () => {
|
||||
const { service, queryBuilder, bookRepo } = makeService();
|
||||
const user = makeUser({ id: 5 });
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
|
||||
bookRepo.findCardsCollapsed.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
id: 20,
|
||||
status: 'present',
|
||||
primaryFileId: null,
|
||||
folderPath: '/books/series-rep',
|
||||
addedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
title: 'First Book',
|
||||
seriesName: 'The Arc',
|
||||
seriesIndex: 1,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
lockedFields: null,
|
||||
bookCount: 3,
|
||||
readCount: 1,
|
||||
coverBookIds: [20],
|
||||
seriesLatestAddedAt: new Date('2024-06-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
authorRows: [],
|
||||
fileRows: [],
|
||||
genreRows: [],
|
||||
progressRows: [],
|
||||
statusRows: [],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const result = await service.queryForLibrary(user, 2, {
|
||||
filter: null,
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
pagination: { page: 0, size: 20 },
|
||||
collapseSeries: true,
|
||||
} as never);
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
|
||||
where: 'WHERE',
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
userId: 5,
|
||||
});
|
||||
expect(bookRepo.findCards).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]!.collapsedSeries).toBeDefined();
|
||||
expect(result.items[0]!.collapsedSeries!.bookCount).toBe(3);
|
||||
});
|
||||
|
||||
it('queryForLibrary uses normal findCards when collapseSeries is false', async () => {
|
||||
const { service, queryBuilder, bookRepo } = makeService();
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
|
||||
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
|
||||
bookRepo.findCards.mockResolvedValue({
|
||||
rows: [],
|
||||
authorRows: [],
|
||||
fileRows: [],
|
||||
genreRows: [],
|
||||
progressRows: [],
|
||||
statusRows: [],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
await service.queryForLibrary(makeUser(), 1, {
|
||||
filter: null,
|
||||
sort: [],
|
||||
pagination: { page: 0, size: 10 },
|
||||
collapseSeries: false,
|
||||
} as never);
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
|
||||
expect(bookRepo.findCards).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queryForLibrary auto-disables collapse when a series filter is active', async () => {
|
||||
const { service, queryBuilder, bookRepo } = makeService();
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
|
||||
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
|
||||
bookRepo.findCards.mockResolvedValue({
|
||||
rows: [],
|
||||
authorRows: [],
|
||||
fileRows: [],
|
||||
genreRows: [],
|
||||
progressRows: [],
|
||||
statusRows: [],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const filterWithSeries = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'series', operator: 'contains', value: 'Dune' }],
|
||||
};
|
||||
|
||||
await service.queryForLibrary(makeUser(), 1, {
|
||||
filter: filterWithSeries,
|
||||
sort: [],
|
||||
pagination: { page: 0, size: 10 },
|
||||
collapseSeries: true,
|
||||
} as never);
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
|
||||
expect(bookRepo.findCards).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('globalQuery routes to findCardsCollapsed when collapseSeries is true', async () => {
|
||||
const { service, libraryService, queryBuilder, bookRepo } = makeService();
|
||||
const user = makeUser({ id: 3 });
|
||||
libraryService.findAll.mockResolvedValue([{ id: 1 }]);
|
||||
queryBuilder.buildWhere.mockReturnValue('GLOBAL_WHERE' as never);
|
||||
bookRepo.findCardsCollapsed.mockResolvedValue({
|
||||
rows: [],
|
||||
authorRows: [],
|
||||
fileRows: [],
|
||||
genreRows: [],
|
||||
progressRows: [],
|
||||
statusRows: [],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
await service.globalQuery(user, {
|
||||
filter: null,
|
||||
sort: [],
|
||||
pagination: { page: 0, size: 10 },
|
||||
collapseSeries: true,
|
||||
} as never);
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith(expect.objectContaining({ userId: 3 }));
|
||||
expect(bookRepo.findCards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('globalQuery auto-disables collapse when a series filter is active', async () => {
|
||||
const { service, libraryService, queryBuilder, bookRepo } = makeService();
|
||||
libraryService.findAll.mockResolvedValue([{ id: 1 }]);
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
|
||||
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
|
||||
bookRepo.findCards.mockResolvedValue({
|
||||
rows: [],
|
||||
authorRows: [],
|
||||
fileRows: [],
|
||||
genreRows: [],
|
||||
progressRows: [],
|
||||
statusRows: [],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const filterWithSeries = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'series', operator: 'equals', value: 'Mistborn' }],
|
||||
};
|
||||
|
||||
await service.globalQuery(makeUser(), {
|
||||
filter: filterWithSeries,
|
||||
sort: [],
|
||||
pagination: { page: 0, size: 10 },
|
||||
collapseSeries: true,
|
||||
} as never);
|
||||
|
||||
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
|
||||
expect(bookRepo.findCards).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('globalQuery throws when pagination window exceeds configured limit', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, readdir, rm, stat } from 'fs/promises';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
|
||||
import { bookCoverDirPath, bookThumbnailPath, findPreferredBookCoverFileName } from '../../common/book-cover-storage';
|
||||
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
resolveUploadPath,
|
||||
} from '@projectx/types';
|
||||
import type { AudiobookChapter, BookKoboState, BookMetadataLockField, BookQuery, BooksPage, MetadataField, ReadStatus } from '@projectx/types';
|
||||
import { assembleBookCards } from './utils/assemble-book-cards';
|
||||
import { assembleBookCards, assembleCollapsedBookCards } from './utils/assemble-book-cards';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||
@@ -219,20 +220,7 @@ export class BookService {
|
||||
this.assertPaginationWindow(query.pagination.page, query.pagination.size);
|
||||
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
|
||||
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id });
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort, user.id);
|
||||
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
orderBy,
|
||||
limit: query.pagination.size,
|
||||
offset: query.pagination.page * query.pagination.size,
|
||||
userId: user.id,
|
||||
});
|
||||
return {
|
||||
items: assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows),
|
||||
total,
|
||||
page: query.pagination.page,
|
||||
size: query.pagination.size,
|
||||
};
|
||||
return this.executeBooksQuery(user.id, where, query);
|
||||
}
|
||||
|
||||
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
|
||||
@@ -240,19 +228,42 @@ export class BookService {
|
||||
const libs = await this.libraryService.findAll(user);
|
||||
const accessibleLibraryIds = libs.map((l) => l.id);
|
||||
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds, userId: user.id });
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort, user.id);
|
||||
return this.executeBooksQuery(user.id, where, query);
|
||||
}
|
||||
|
||||
private async executeBooksQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<BooksPage> {
|
||||
const { page, size } = query.pagination;
|
||||
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesFilter(query.filter);
|
||||
|
||||
if (shouldCollapse) {
|
||||
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows, total } = await this.bookRepo.findCardsCollapsed({
|
||||
where,
|
||||
sort: query.sort,
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
userId,
|
||||
});
|
||||
return {
|
||||
items: assembleCollapsedBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows),
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId);
|
||||
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
orderBy,
|
||||
limit: query.pagination.size,
|
||||
offset: query.pagination.page * query.pagination.size,
|
||||
userId: user.id,
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
userId,
|
||||
});
|
||||
return {
|
||||
items: assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows),
|
||||
total,
|
||||
page: query.pagination.page,
|
||||
size: query.pagination.size,
|
||||
page,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const SORT_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const bookQuerySchema = z.object({
|
||||
collapseSeries: z.boolean().optional(),
|
||||
filter: groupRuleSchema(5).optional(),
|
||||
sort: z
|
||||
.array(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCollapsePreference } from '@projectx/types';
|
||||
import type { SeriesCollapsePreferences } from '@projectx/types';
|
||||
|
||||
describe('resolveCollapsePreference', () => {
|
||||
it('returns false when prefs is undefined', () => {
|
||||
expect(resolveCollapsePreference(undefined, {})).toBe(false);
|
||||
expect(resolveCollapsePreference(undefined, { libraryId: 1 })).toBe(false);
|
||||
expect(resolveCollapsePreference(undefined, { collectionId: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns global value when no context is provided', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: true, libraries: {}, collections: {} };
|
||||
expect(resolveCollapsePreference(prefs, {})).toBe(true);
|
||||
|
||||
const prefsOff: SeriesCollapsePreferences = { global: false, libraries: {}, collections: {} };
|
||||
expect(resolveCollapsePreference(prefsOff, {})).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to global when no library override exists', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: true, libraries: { '2': false }, collections: {} };
|
||||
expect(resolveCollapsePreference(prefs, { libraryId: 99 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns library override when present', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: true, libraries: { '3': false }, collections: {} };
|
||||
expect(resolveCollapsePreference(prefs, { libraryId: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to global when no collection override exists', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: false, libraries: {}, collections: { '10': true } };
|
||||
expect(resolveCollapsePreference(prefs, { collectionId: 99 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns collection override when present', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: false, libraries: {}, collections: { '7': true } };
|
||||
expect(resolveCollapsePreference(prefs, { collectionId: 7 })).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers collection override over library override', () => {
|
||||
const prefs: SeriesCollapsePreferences = {
|
||||
global: false,
|
||||
libraries: { '1': true },
|
||||
collections: { '5': false },
|
||||
};
|
||||
expect(resolveCollapsePreference(prefs, { collectionId: 5, libraryId: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to library override when collection override is absent', () => {
|
||||
const prefs: SeriesCollapsePreferences = {
|
||||
global: false,
|
||||
libraries: { '1': true },
|
||||
collections: {},
|
||||
};
|
||||
expect(resolveCollapsePreference(prefs, { collectionId: 99, libraryId: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves with string keys matching numeric IDs', () => {
|
||||
const prefs: SeriesCollapsePreferences = { global: false, libraries: { '42': true }, collections: {} };
|
||||
expect(resolveCollapsePreference(prefs, { libraryId: 42 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assembleBookCards } from './assemble-book-cards';
|
||||
import { assembleBookCards, assembleCollapsedBookCards, collapseBookCards } from './assemble-book-cards';
|
||||
|
||||
function makeBookRow(id: number, overrides?: Partial<Parameters<typeof assembleBookCards>[0][number]>) {
|
||||
return {
|
||||
@@ -191,3 +191,245 @@ describe('assembleBookCards', () => {
|
||||
expect(card.readingProgress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function makeCollapsedRow(id: number, overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id,
|
||||
status: 'ready',
|
||||
primaryFileId: null,
|
||||
folderPath: `/books/folder-${id}`,
|
||||
addedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
title: `Book ${id}`,
|
||||
seriesName: 'Test Series',
|
||||
seriesIndex: id,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
lockedFields: null,
|
||||
bookCount: 3,
|
||||
readCount: 1,
|
||||
coverBookIds: [id],
|
||||
seriesLatestAddedAt: new Date('2024-06-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBookCard(id: number, overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id,
|
||||
status: 'ready',
|
||||
title: `Book ${id}`,
|
||||
authors: [] as string[],
|
||||
seriesName: null as string | null,
|
||||
seriesIndex: null as number | null,
|
||||
files: [] as { id: number; format: string | null; role: string }[],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [] as string[],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null as { status: string } | null,
|
||||
addedAt: '2024-01-01T00:00:00.000Z',
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('assembleCollapsedBookCards', () => {
|
||||
it('sets collapsedSeries on rows with non-null bookCount', () => {
|
||||
const rows = [makeCollapsedRow(1)];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries).toBeDefined();
|
||||
expect(card!.collapsedSeries!.bookCount).toBe(3);
|
||||
expect(card!.collapsedSeries!.readCount).toBe(1);
|
||||
expect(card!.collapsedSeries!.coverBookIds).toEqual([1]);
|
||||
});
|
||||
|
||||
it('does not set collapsedSeries on rows with null bookCount (standalones)', () => {
|
||||
const rows = [makeCollapsedRow(1, { bookCount: null })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts seriesLatestAddedAt Date to ISO string', () => {
|
||||
const date = new Date('2024-06-15T12:00:00.000Z');
|
||||
const rows = [makeCollapsedRow(1, { seriesLatestAddedAt: date })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries!.seriesLatestAddedAt).toBe('2024-06-15T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('sets seriesLatestAddedAt to null when date is null', () => {
|
||||
const rows = [makeCollapsedRow(1, { seriesLatestAddedAt: null })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries!.seriesLatestAddedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('sets coverBookIds to empty array when null', () => {
|
||||
const rows = [makeCollapsedRow(1, { coverBookIds: null })];
|
||||
const [card] = assembleCollapsedBookCards(rows, [], [], [], []);
|
||||
|
||||
expect(card!.collapsedSeries!.coverBookIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves base card fields alongside collapsedSeries', () => {
|
||||
const rows = [makeCollapsedRow(10, { title: 'Book Ten' })];
|
||||
const authorRows = [{ bookId: 10, name: 'Author X' }];
|
||||
const [card] = assembleCollapsedBookCards(rows, authorRows, [], [], []);
|
||||
|
||||
expect(card!.title).toBe('Book Ten');
|
||||
expect(card!.authors).toEqual(['Author X']);
|
||||
expect(card!.collapsedSeries).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseBookCards', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(collapseBookCards([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns standalone books unchanged (no collapsedSeries)', () => {
|
||||
const cards = [makeBookCard(1, { title: 'Alpha' }), makeBookCard(2, { title: 'Beta' })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((c) => c.collapsedSeries === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses multiple books in the same series into one card', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'Dune', seriesIndex: 1 }),
|
||||
makeBookCard(2, { seriesName: 'Dune', seriesIndex: 2 }),
|
||||
makeBookCard(3, { seriesName: 'Dune', seriesIndex: 3 }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.collapsedSeries!.bookCount).toBe(3);
|
||||
});
|
||||
|
||||
it('collapses series case-insensitively', () => {
|
||||
const cards = [makeBookCard(1, { seriesName: 'DUNE', seriesIndex: 1 }), makeBookCard(2, { seriesName: 'dune', seriesIndex: 2 })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.collapsedSeries!.bookCount).toBe(2);
|
||||
});
|
||||
|
||||
it('treats books with null seriesName as standalones', () => {
|
||||
const cards = [makeBookCard(1, { seriesName: null }), makeBookCard(2, { seriesName: ' ' })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((c) => c.collapsedSeries === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('picks representative with lowest seriesIndex', () => {
|
||||
const cards = [
|
||||
makeBookCard(3, { seriesName: 'Mistborn', seriesIndex: 3 }),
|
||||
makeBookCard(1, { seriesName: 'Mistborn', seriesIndex: 1 }),
|
||||
makeBookCard(2, { seriesName: 'Mistborn', seriesIndex: 2 }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to addedAt order when seriesIndex is null', () => {
|
||||
const cards = [
|
||||
makeBookCard(2, { seriesName: 'Arc', seriesIndex: null, addedAt: '2024-03-01T00:00:00.000Z' }),
|
||||
makeBookCard(1, { seriesName: 'Arc', seriesIndex: null, addedAt: '2024-01-01T00:00:00.000Z' }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('prefers books with indexes over books without when sorting', () => {
|
||||
const cards = [makeBookCard(2, { seriesName: 'Arc', seriesIndex: null }), makeBookCard(1, { seriesName: 'Arc', seriesIndex: 1 })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('counts readCount correctly', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'Series', readStatus: { status: 'read' } }),
|
||||
makeBookCard(2, { seriesName: 'Series', readStatus: { status: 'reading' } }),
|
||||
makeBookCard(3, { seriesName: 'Series', readStatus: { status: 'read' } }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.readCount).toBe(2);
|
||||
});
|
||||
|
||||
it('includes bookCount equal to group size', () => {
|
||||
const cards = [makeBookCard(1, { seriesName: 'S' }), makeBookCard(2, { seriesName: 'S' }), makeBookCard(3, { seriesName: 'S' })];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.bookCount).toBe(3);
|
||||
});
|
||||
|
||||
it('prefers books with covers for coverBookIds', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, hasCover: false }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2, hasCover: true }),
|
||||
makeBookCard(3, { seriesName: 'S', seriesIndex: 3, hasCover: true }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.coverBookIds).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('falls back to all sorted books when none have covers', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'S', seriesIndex: 1, hasCover: false }),
|
||||
makeBookCard(2, { seriesName: 'S', seriesIndex: 2, hasCover: false }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.coverBookIds).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('caps coverBookIds at 4', () => {
|
||||
const cards = Array.from({ length: 6 }, (_, i) => makeBookCard(i + 1, { seriesName: 'BigSeries', seriesIndex: i + 1, hasCover: true }));
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result[0]!.collapsedSeries!.coverBookIds).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('returns results in first-occurrence order, preserving input sequence', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { title: 'Zephyr', seriesName: null }),
|
||||
makeBookCard(2, { seriesName: 'Amber', seriesIndex: 1, title: null }),
|
||||
makeBookCard(3, { title: 'Mango', seriesName: null }),
|
||||
makeBookCard(4, { seriesName: 'Cobalt', seriesIndex: 1, title: null }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
const keys = result.map((c) => (c.seriesName?.trim() ?? c.title ?? '').toLowerCase());
|
||||
expect(keys).toEqual(['zephyr', 'amber', 'mango', 'cobalt']);
|
||||
});
|
||||
|
||||
it('keeps multiple distinct series separate', () => {
|
||||
const cards = [
|
||||
makeBookCard(1, { seriesName: 'Alpha' }),
|
||||
makeBookCard(2, { seriesName: 'Alpha' }),
|
||||
makeBookCard(3, { seriesName: 'Beta' }),
|
||||
makeBookCard(4, { seriesName: 'Beta' }),
|
||||
makeBookCard(5, { seriesName: 'Beta' }),
|
||||
];
|
||||
const result = collapseBookCards(cards);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const alpha = result.find((c) => c.seriesName === 'Alpha');
|
||||
const beta = result.find((c) => c.seriesName === 'Beta');
|
||||
expect(alpha!.collapsedSeries!.bookCount).toBe(2);
|
||||
expect(beta!.collapsedSeries!.bookCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { basename } from 'path';
|
||||
|
||||
import type { BookCard, UserBookStatus } from '@projectx/types';
|
||||
import type { BookCard, CollapsedSeriesInfo, UserBookStatus } from '@projectx/types';
|
||||
|
||||
type BookRow = {
|
||||
id: number;
|
||||
@@ -18,6 +18,13 @@ type BookRow = {
|
||||
lockedFields: string[] | null;
|
||||
};
|
||||
|
||||
type CollapsedBookRow = BookRow & {
|
||||
bookCount: number | null;
|
||||
readCount: number | null;
|
||||
coverBookIds: number[] | null;
|
||||
seriesLatestAddedAt: Date | null;
|
||||
};
|
||||
|
||||
type NameRow = { bookId: number; name: string };
|
||||
type FileRow = { bookId: number; id: number; format: string | null; role: string };
|
||||
type ProgressRow = { bookFileId: number; percentage: number | null };
|
||||
@@ -106,3 +113,88 @@ export function assembleBookCards(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function assembleCollapsedBookCards(
|
||||
rows: CollapsedBookRow[],
|
||||
authorRows: NameRow[],
|
||||
fileRows: FileRow[],
|
||||
genreRows: NameRow[],
|
||||
progressRows: ProgressRow[],
|
||||
statusRows: StatusRow[] = [],
|
||||
): BookCard[] {
|
||||
const base = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows);
|
||||
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const row = rows[i];
|
||||
if (row && row.bookCount !== null) {
|
||||
const collapsed: CollapsedSeriesInfo = {
|
||||
bookCount: row.bookCount,
|
||||
readCount: row.readCount ?? 0,
|
||||
coverBookIds: row.coverBookIds ?? [],
|
||||
seriesLatestAddedAt: row.seriesLatestAddedAt?.toISOString() ?? null,
|
||||
};
|
||||
base[i] = { ...base[i]!, collapsedSeries: collapsed };
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export function collapseBookCards(cards: BookCard[]): BookCard[] {
|
||||
const seriesGroups = new Map<string, { firstIndex: number; books: BookCard[] }>();
|
||||
const standalones: { index: number; card: BookCard }[] = [];
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const card = cards[i]!;
|
||||
if (card.seriesName?.trim()) {
|
||||
const key = card.seriesName.trim().toLowerCase();
|
||||
const group = seriesGroups.get(key);
|
||||
if (group) {
|
||||
group.books.push(card);
|
||||
} else {
|
||||
seriesGroups.set(key, { firstIndex: i, books: [card] });
|
||||
}
|
||||
} else {
|
||||
standalones.push({ index: i, card });
|
||||
}
|
||||
}
|
||||
|
||||
const result: { index: number; card: BookCard }[] = [];
|
||||
|
||||
for (const [, { firstIndex, books: group }] of seriesGroups) {
|
||||
const sorted = [...group].sort((a, b) => {
|
||||
if (a.seriesIndex !== null && b.seriesIndex !== null) return a.seriesIndex - b.seriesIndex;
|
||||
if (a.seriesIndex !== null) return -1;
|
||||
if (b.seriesIndex !== null) return 1;
|
||||
return new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime();
|
||||
});
|
||||
|
||||
const representative = { ...sorted[0]! };
|
||||
const readCount = group.filter((b) => b.readStatus?.status === 'read').length;
|
||||
const seriesLatestAddedAt =
|
||||
[...group]
|
||||
.map((b) => b.addedAt)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
const coverIds = sorted
|
||||
.filter((b) => b.hasCover)
|
||||
.slice(0, 4)
|
||||
.map((b) => b.id);
|
||||
const fallbackIds = sorted.slice(0, 4).map((b) => b.id);
|
||||
|
||||
representative.collapsedSeries = {
|
||||
bookCount: group.length,
|
||||
readCount,
|
||||
coverBookIds: coverIds.length > 0 ? coverIds : fallbackIds,
|
||||
seriesLatestAddedAt,
|
||||
};
|
||||
result.push({ index: firstIndex, card: representative });
|
||||
}
|
||||
|
||||
for (const { index, card } of standalones) {
|
||||
result.push({ index, card });
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.index - b.index).map((r) => r.card);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('CollectionController', () => {
|
||||
|
||||
await controller.getBooks(10, USER, 0, 50);
|
||||
|
||||
expect(service.getBooks).toHaveBeenCalledWith(10, USER, 0, 50);
|
||||
expect(service.getBooks).toHaveBeenCalledWith(10, USER, 0, 50, undefined);
|
||||
});
|
||||
|
||||
it('rejects invalid page and size boundaries', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -151,9 +152,10 @@ export class CollectionController {
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
|
||||
@Query('collapseSeries', new ParseBoolPipe({ optional: true })) collapseSeries?: boolean,
|
||||
) {
|
||||
this.validateSizeQuery(size);
|
||||
this.validatePageQuery(page, size);
|
||||
return this.collectionService.getBooks(id, user, page, size);
|
||||
return this.collectionService.getBooks(id, user, page, size, collapseSeries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,17 @@ export class CollectionRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findAllBookIds(collectionId: number, libraryIds: number[]): Promise<number[]> {
|
||||
if (libraryIds.length === 0) return [];
|
||||
const rows = await this.db
|
||||
.select({ bookId: collectionBooks.bookId })
|
||||
.from(collectionBooks)
|
||||
.innerJoin(books, eq(books.id, collectionBooks.bookId))
|
||||
.where(and(eq(collectionBooks.collectionId, collectionId), inArray(books.libraryId, libraryIds)))
|
||||
.orderBy(collectionBooks.addedAt, collectionBooks.bookId);
|
||||
return rows.map((row) => row.bookId);
|
||||
}
|
||||
|
||||
async updateDisplayOrders(userId: number, order: { id: number; displayOrder: number }[]) {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const item of order) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { BooksPage } from '@projectx/types';
|
||||
import { assembleBookCards } from '../book/utils/assemble-book-cards';
|
||||
import { assembleBookCards, collapseBookCards } from '../book/utils/assemble-book-cards';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
@@ -201,13 +201,42 @@ export class CollectionService {
|
||||
}
|
||||
}
|
||||
|
||||
async getBooks(id: number, user: RequestUser, page: number, size: number): Promise<BooksPage> {
|
||||
async getBooks(id: number, user: RequestUser, page: number, size: number, collapseSeries?: boolean): Promise<BooksPage> {
|
||||
const event = 'collection.get_books';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] collectionId=${id} userId=${user.id} page=${page} size=${size} - get collection books started`);
|
||||
this.logger.log(
|
||||
`[${event}] [start] collectionId=${id} userId=${user.id} page=${page} size=${size} collapseSeries=${collapseSeries ?? false} - get collection books started`,
|
||||
);
|
||||
try {
|
||||
await this.findCollectionForUserOrThrow(id, user);
|
||||
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
||||
|
||||
if (collapseSeries) {
|
||||
const allBookIds = await this.collectionRepo.findAllBookIds(id, libraryIds);
|
||||
if (allBookIds.length === 0) {
|
||||
this.logger.log(
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} total=0 itemCount=0 - get collection books completed`,
|
||||
);
|
||||
return { items: [], total: 0, page, size };
|
||||
}
|
||||
|
||||
const { rows, authorRows, fileRows, genreRows, progressRows, statusRows } = await this.bookReadService.findCardsByBookIds(
|
||||
allBookIds,
|
||||
user.id,
|
||||
);
|
||||
const orderMap = new Map(allBookIds.map((bookId, index) => [bookId, index]));
|
||||
const allItems = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows).sort(
|
||||
(left, right) => (orderMap.get(left.id) ?? 0) - (orderMap.get(right.id) ?? 0),
|
||||
);
|
||||
const collapsed = collapseBookCards(allItems);
|
||||
const pageItems = collapsed.slice(page * size, page * size + size);
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] collectionId=${id} durationMs=${Date.now() - startedAt} total=${collapsed.length} itemCount=${pageItems.length} - get collection books completed`,
|
||||
);
|
||||
return { items: pageItems, total: collapsed.length, page, size };
|
||||
}
|
||||
|
||||
const bookPage = await this.collectionRepo.findBookIdsPage(id, libraryIds, page, size);
|
||||
if (bookPage.bookIds.length === 0) {
|
||||
this.logger.log(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsBoolean, IsOptional, registerDecorator, ValidationOptions } from 'class-validator';
|
||||
|
||||
function IsBooleanRecord(validationOptions?: ValidationOptions) {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'isBooleanRecord',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
return Object.values(value as Record<string, unknown>).every((v) => typeof v === 'boolean');
|
||||
},
|
||||
defaultMessage() {
|
||||
return `${propertyName} must be a record with string keys and boolean values`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class UpdateSeriesCollapsePreferencesDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
global?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBooleanRecord()
|
||||
libraries?: Record<string, boolean>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBooleanRecord()
|
||||
collections?: Record<string, boolean>;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { SetLibrariesDto } from './dto/set-libraries.dto';
|
||||
import { SetPermissionsDto } from './dto/set-permissions.dto';
|
||||
import { UpdateMeDto } from './dto/update-me.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse-preferences.dto';
|
||||
import { MAX_USER_AVATAR_BYTES } from './user-avatar.service';
|
||||
import { UserAvatarService } from './user-avatar.service';
|
||||
import { UserService } from './user.service';
|
||||
@@ -71,6 +72,12 @@ export class UserController {
|
||||
return this.userService.updateMe(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('me/series-collapse-preferences')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
updateSeriesCollapsePreferences(@CurrentUser() user: RequestUser, @Body() dto: UpdateSeriesCollapsePreferencesDto) {
|
||||
return this.userService.updateSeriesCollapsePreferences(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('me/avatar')
|
||||
@Auditable({
|
||||
action: AuditAction.UserSelfUpdate,
|
||||
|
||||
@@ -384,3 +384,125 @@ describe('UserService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserService.updateSeriesCollapsePreferences', () => {
|
||||
let service: UserService;
|
||||
const userRepo = {
|
||||
findByUsername: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
findByOidcSubject: vi.fn(),
|
||||
linkOidcIdentity: vi.fn(),
|
||||
createOidcUser: vi.fn(),
|
||||
setPermissions: vi.fn(),
|
||||
generateResetToken: vi.fn(),
|
||||
incrementTokenVersion: vi.fn(),
|
||||
findByIdWithPermissions: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findAssignable: vi.fn(),
|
||||
update: vi.fn(),
|
||||
countOtherSuperusers: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
setSuperuser: vi.fn(),
|
||||
assignViewerLibraries: vi.fn(),
|
||||
findLibraryIdsByUserId: vi.fn(),
|
||||
replaceViewerLibraries: vi.fn(),
|
||||
findExistingLibraryIds: vi.fn(),
|
||||
};
|
||||
const config = { get: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
config.get.mockReturnValue('http://localhost:5173');
|
||||
service = new UserService(userRepo as any, config as any);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateSeriesCollapsePreferences(99, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('initialises preferences when none exist and sets global flag', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 1, settings: {} });
|
||||
|
||||
await service.updateSeriesCollapsePreferences(1, { global: true });
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(1, {
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: true, libraries: {}, collections: {} },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges global preference with existing prefs', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({
|
||||
id: 1,
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: false, libraries: { '3': true }, collections: { '7': false } },
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateSeriesCollapsePreferences(1, { global: true });
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(1, {
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: true, libraries: { '3': true }, collections: { '7': false } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges a library override without disturbing others', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({
|
||||
id: 1,
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: false, libraries: { '1': true }, collections: {} },
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateSeriesCollapsePreferences(1, { libraries: { '2': false } });
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(1, {
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: false, libraries: { '1': true, '2': false }, collections: {} },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges a collection override without disturbing library overrides', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({
|
||||
id: 1,
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false } },
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateSeriesCollapsePreferences(1, { collections: { '9': true } });
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(1, {
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false, '9': true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites an existing override when the same key is provided again', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({
|
||||
id: 1,
|
||||
settings: {
|
||||
seriesCollapsePreferences: { global: false, libraries: { '3': true }, collections: {} },
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateSeriesCollapsePreferences(1, { libraries: { '3': false } });
|
||||
|
||||
expect(userRepo.update).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
seriesCollapsePreferences: expect.objectContaining({ libraries: { '3': false } }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,14 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Permission } from '@projectx/types';
|
||||
import type { UserSettings } from '@projectx/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { SetPermissionsDto } from './dto/set-permissions.dto';
|
||||
import { UpdateMeDto } from './dto/update-me.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse-preferences.dto';
|
||||
import { UserRepository } from './user.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -253,4 +255,16 @@ export class UserService {
|
||||
throw new BadRequestException(`Unknown library IDs: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSeriesCollapsePreferences(userId: number, dto: UpdateSeriesCollapsePreferencesDto): Promise<void> {
|
||||
const existing = await this.userRepo.findByIdWithPermissions(userId);
|
||||
if (!existing) throw new NotFoundException('User not found');
|
||||
const currentPrefs = (existing.settings as UserSettings)?.seriesCollapsePreferences ?? { global: false, libraries: {}, collections: {} };
|
||||
const merged = {
|
||||
global: dto.global !== undefined ? dto.global : currentPrefs.global,
|
||||
libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) },
|
||||
collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) },
|
||||
};
|
||||
await this.userRepo.update(userId, { settings: { seriesCollapsePreferences: merged } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user