feat(series): add series collapse with per-library preferences

This commit is contained in:
neon
2026-04-15 18:25:34 -06:00
parent 485bea5e5b
commit cbaa3b0f1b
37 changed files with 2092 additions and 50 deletions
+4
View File
@@ -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>
+58 -2
View File
@@ -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"
+57 -2
View File
@@ -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">
+1
View File
@@ -21,6 +21,7 @@ export interface UserSettings {
tourCompleted?: boolean;
};
notificationPreferences?: import("./notification").NotificationPreferences;
seriesCollapsePreferences?: import("./series-collapse").SeriesCollapsePreferences;
}
export interface AuthUser {
+1
View File
@@ -45,6 +45,7 @@ export type BookCard = {
addedAt: string;
hasCover: boolean;
hasMetadataLocks: boolean;
collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo;
};
export type BookDetailFile = {
+1
View File
@@ -28,3 +28,4 @@ export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
export * from "./series";
export * from "./series-collapse";
+34 -2
View File
@@ -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;
};
+25
View File
@@ -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');
+7 -4
View File
@@ -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));
+220
View File
@@ -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();
+32 -21
View File
@@ -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 } }),
}),
}),
);
});
});
+14
View File
@@ -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 } });
}
}