mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): reshuffle random sort per visit instead of per day
Seed the random sort field from a per-view randomSeed on BookQuery rather than a day counter, so a reload or a scope change draws a new order while every page of one listing stays coherent. Callers that omit the seed keep the daily fallback. Adds a shuffle button to the library, smart scope, and collection toolbars when the active sort is random. Closes #1153
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Shuffle } from '@lucide/vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Icon only, for the dense desktop toolbar. */
|
||||||
|
compact?: boolean
|
||||||
|
/** Hide below the `sm` breakpoint, where the mobile control sheet carries its own copy. */
|
||||||
|
desktopOnly?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ shuffle: [] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const visibility = computed(() => (props.desktopOnly ? 'hidden sm:flex' : 'flex'))
|
||||||
|
|
||||||
|
function handleShuffle() {
|
||||||
|
emit('shuffle')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('views.bookView.shuffleAria')"
|
||||||
|
class="h-8 items-center justify-center gap-1.5 rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:text-foreground"
|
||||||
|
:class="[visibility, compact ? 'w-8' : 'px-2.5 text-sm']"
|
||||||
|
@click="handleShuffle"
|
||||||
|
>
|
||||||
|
<Shuffle :size="13" />
|
||||||
|
<span v-if="!compact">{{ t('views.bookView.shuffle') }}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ t('views.bookView.shuffleHint') }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { nextTick, ref } from 'vue'
|
import { defineComponent, nextTick, ref } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { BookCard, BooksPage, GroupRule, JumpBucket, SortSpec } from '@bookorbit/types'
|
import type { BookCard, BooksPage, GroupRule, JumpBucket, SortSpec } from '@bookorbit/types'
|
||||||
import { COLLECTION_DEFAULT_SORT } from '../../lib/sort-defaults'
|
import { COLLECTION_DEFAULT_SORT } from '../../lib/sort-defaults'
|
||||||
@@ -403,4 +404,100 @@ describe('useBookViewWindow', () => {
|
|||||||
|
|
||||||
win.releaseRailGutter()
|
win.releaseRailGutter()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits the shuffle seed until a sort tier is random', async () => {
|
||||||
|
mockApi(120)
|
||||||
|
const { win } = setup()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(win.randomSortActive.value).toBe(false)
|
||||||
|
expect(listRequestBodies()[0]).not.toHaveProperty('randomSeed')
|
||||||
|
|
||||||
|
win.sort.value = [{ field: 'random', dir: 'asc' }]
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(win.randomSortActive.value).toBe(true)
|
||||||
|
const seed = listRequestBodies().at(-1)?.randomSeed
|
||||||
|
expect(typeof seed).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends one seed for every page of a shuffled listing', async () => {
|
||||||
|
mockApi(250)
|
||||||
|
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
await win.ensureRange(100, 199)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
const seeds = listRequestBodies().map((body) => body.randomSeed)
|
||||||
|
expect(seeds.length).toBeGreaterThan(1)
|
||||||
|
expect(new Set(seeds).size).toBe(1)
|
||||||
|
expect(typeof seeds[0]).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reshuffles into a new seed and refetches', async () => {
|
||||||
|
mockApi(120)
|
||||||
|
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
|
||||||
|
await flush()
|
||||||
|
const first = listRequestBodies().at(-1)?.randomSeed
|
||||||
|
|
||||||
|
win.reshuffle()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
const second = listRequestBodies().at(-1)?.randomSeed
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
expect(listRequestBodies().length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reshuffles when the scope changes so two scopes never share an order', async () => {
|
||||||
|
mockApi(120)
|
||||||
|
const { win, scopeId } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
|
||||||
|
await flush()
|
||||||
|
const first = listRequestBodies().at(-1)?.randomSeed
|
||||||
|
|
||||||
|
scopeId.value = 2
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
|
||||||
|
expect(win.randomSortActive.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reshuffles when a kept-alive random listing is revisited', async () => {
|
||||||
|
mockApi(120)
|
||||||
|
const visible = ref(true)
|
||||||
|
|
||||||
|
const Source = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useBookViewWindow({
|
||||||
|
scopeId: ref(1),
|
||||||
|
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
|
||||||
|
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
|
||||||
|
viewMode: ref('table'),
|
||||||
|
defaultSort: [{ field: 'random', dir: 'asc' }],
|
||||||
|
})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
template: '<div />',
|
||||||
|
})
|
||||||
|
const Harness = defineComponent({
|
||||||
|
components: { Source },
|
||||||
|
setup() {
|
||||||
|
return { visible }
|
||||||
|
},
|
||||||
|
template: '<KeepAlive><Source v-if="visible" /></KeepAlive>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(Harness)
|
||||||
|
await flush()
|
||||||
|
const first = listRequestBodies().at(-1)?.randomSeed
|
||||||
|
fetchMock.mockClear()
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
await nextTick()
|
||||||
|
visible.value = true
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { computed, ref, type Ref } from 'vue'
|
import { computed, getCurrentInstance, onActivated, ref, watch, type Ref } from 'vue'
|
||||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
import { jumpBucketKindForSort, type GroupRule, type JumpBucket, type SortSpec } from '@bookorbit/types'
|
import {
|
||||||
|
MAX_RANDOM_SORT_SEED,
|
||||||
|
createRandomSortSeed,
|
||||||
|
hasRandomSort,
|
||||||
|
jumpBucketKindForSort,
|
||||||
|
type GroupRule,
|
||||||
|
type JumpBucket,
|
||||||
|
type SortSpec,
|
||||||
|
} from '@bookorbit/types'
|
||||||
import { useBookProgressRefresh } from './useBookProgressRefresh'
|
import { useBookProgressRefresh } from './useBookProgressRefresh'
|
||||||
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
|
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
|
||||||
import { useJumpBuckets } from './useJumpBuckets'
|
import { useJumpBuckets } from './useJumpBuckets'
|
||||||
@@ -39,8 +47,36 @@ export function useBookViewWindow(options: {
|
|||||||
const filter = ref<GroupRule | undefined>(undefined)
|
const filter = ref<GroupRule | undefined>(undefined)
|
||||||
const sort = ref<SortSpec[]>(copySort(options.defaultSort ?? DEFAULT_SORT))
|
const sort = ref<SortSpec[]>(copySort(options.defaultSort ?? DEFAULT_SORT))
|
||||||
|
|
||||||
|
// One shuffle per visit: the seed is minted here and travels with every page request, so
|
||||||
|
// paging stays coherent while a reload, a different scope, or the shuffle button re-rolls it.
|
||||||
|
const randomSeed = ref(createRandomSortSeed())
|
||||||
|
const randomSortActive = computed(() => hasRandomSort(sort.value))
|
||||||
|
|
||||||
|
function reshuffle() {
|
||||||
|
const next = createRandomSortSeed()
|
||||||
|
randomSeed.value = next === randomSeed.value ? (next + 1) % (MAX_RANDOM_SORT_SEED + 1) : next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered before useBookWindow so a sort or scope change re-rolls the seed in the same flush
|
||||||
|
// the window reads the query, rather than costing a second round trip.
|
||||||
|
watch(randomSortActive, (active) => {
|
||||||
|
if (active) reshuffle()
|
||||||
|
})
|
||||||
|
watch(options.scopeId, () => {
|
||||||
|
reshuffle()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (getCurrentInstance()) {
|
||||||
|
let hasActivated = false
|
||||||
|
onActivated(() => {
|
||||||
|
if (hasActivated && randomSortActive.value) reshuffle()
|
||||||
|
hasActivated = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const query = computed<BookWindowQuery>(() => ({
|
const query = computed<BookWindowQuery>(() => ({
|
||||||
sort: sort.value,
|
sort: sort.value,
|
||||||
|
...(randomSortActive.value ? { randomSeed: randomSeed.value } : {}),
|
||||||
...(filter.value ? { filter: filter.value } : {}),
|
...(filter.value ? { filter: filter.value } : {}),
|
||||||
...(options.collapseEnabled?.value ? { collapseSeries: true } : {}),
|
...(options.collapseEnabled?.value ? { collapseSeries: true } : {}),
|
||||||
...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}),
|
...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}),
|
||||||
@@ -130,6 +166,8 @@ export function useBookViewWindow(options: {
|
|||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
query,
|
query,
|
||||||
|
randomSortActive,
|
||||||
|
reshuffle,
|
||||||
firstVisibleIndex,
|
firstVisibleIndex,
|
||||||
handleFirstVisibleIndex,
|
handleFirstVisibleIndex,
|
||||||
handleRange,
|
handleRange,
|
||||||
|
|||||||
@@ -7989,6 +7989,9 @@
|
|||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"sortOn": "On",
|
"sortOn": "On",
|
||||||
"resetSort": "Reset sort to default",
|
"resetSort": "Reset sort to default",
|
||||||
|
"shuffle": "Shuffle",
|
||||||
|
"shuffleAria": "Shuffle the book order",
|
||||||
|
"shuffleHint": "Draw a new random order",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"clearAllFilters": "Clear all filters",
|
"clearAllFilters": "Clear all filters",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
||||||
|
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
|
||||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||||
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
|
||||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||||
@@ -105,6 +106,8 @@ const {
|
|||||||
initialized: booksInitialized,
|
initialized: booksInitialized,
|
||||||
error: booksError,
|
error: booksError,
|
||||||
sort: tableSort,
|
sort: tableSort,
|
||||||
|
randomSortActive,
|
||||||
|
reshuffle,
|
||||||
reset: resetBooks,
|
reset: resetBooks,
|
||||||
contiguousPrefix,
|
contiguousPrefix,
|
||||||
hasMorePrefix,
|
hasMorePrefix,
|
||||||
@@ -544,6 +547,7 @@ defineOptions({ name: 'CollectionView' })
|
|||||||
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
|
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -680,6 +684,7 @@ defineOptions({ name: 'CollectionView' })
|
|||||||
>
|
>
|
||||||
<X :size="13" />
|
<X :size="13" />
|
||||||
</button>
|
</button>
|
||||||
|
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
|
||||||
<button
|
<button
|
||||||
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
|
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
|
||||||
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"
|
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"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import TableColumnPanel from '@/features/book/components/TableColumnPanel.vue'
|
|||||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||||
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
|
||||||
|
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||||
import ViewHeader from '@/components/ViewHeader.vue'
|
import ViewHeader from '@/components/ViewHeader.vue'
|
||||||
@@ -119,6 +120,8 @@ const {
|
|||||||
error,
|
error,
|
||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
|
randomSortActive,
|
||||||
|
reshuffle,
|
||||||
reset: resetBooks,
|
reset: resetBooks,
|
||||||
updateBooks,
|
updateBooks,
|
||||||
contiguousPrefix,
|
contiguousPrefix,
|
||||||
@@ -645,6 +648,7 @@ defineOptions({ name: 'HomeView' })
|
|||||||
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
|
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
|
||||||
<div class="hidden sm:block w-px h-5 bg-border shrink-0" />
|
<div class="hidden sm:block w-px h-5 bg-border shrink-0" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -804,6 +808,8 @@ defineOptions({ name: 'HomeView' })
|
|||||||
<X :size="13" />
|
<X :size="13" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="toggleFilterPanel"
|
@click="toggleFilterPanel"
|
||||||
class="flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-sm transition-colors"
|
class="flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-sm transition-colors"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
|
|||||||
import { useViewSearch } from '@/features/book/composables/useViewSearch'
|
import { useViewSearch } from '@/features/book/composables/useViewSearch'
|
||||||
import FilterSummary from '@/features/book/components/FilterSummary.vue'
|
import FilterSummary from '@/features/book/components/FilterSummary.vue'
|
||||||
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
|
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
|
||||||
|
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
|
||||||
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
|
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
|
||||||
@@ -101,6 +102,8 @@ const {
|
|||||||
initialized: booksInitialized,
|
initialized: booksInitialized,
|
||||||
error: booksError,
|
error: booksError,
|
||||||
sort: tableSort,
|
sort: tableSort,
|
||||||
|
randomSortActive,
|
||||||
|
reshuffle,
|
||||||
reset: resetBooks,
|
reset: resetBooks,
|
||||||
contiguousPrefix,
|
contiguousPrefix,
|
||||||
hasMorePrefix,
|
hasMorePrefix,
|
||||||
@@ -573,6 +576,7 @@ defineOptions({ name: 'SmartScopeView' })
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent>
|
<TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<button
|
<button
|
||||||
@@ -727,6 +731,7 @@ defineOptions({ name: 'SmartScopeView' })
|
|||||||
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
|
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
|
||||||
<span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span>
|
<span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
|
||||||
<button
|
<button
|
||||||
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
|
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
|
||||||
@click="openMetadataExport"
|
@click="openMetadataExport"
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ export type GroupRule = {
|
|||||||
* - `rating` - from `user_book_ratings.rating` (per-user, correlated subquery)
|
* - `rating` - from `user_book_ratings.rating` (per-user, correlated subquery)
|
||||||
* - `format` - from `book_files.format` for the primary file (correlated subquery)
|
* - `format` - from `book_files.format` for the primary file (correlated subquery)
|
||||||
* - `publishedDate` - uses full dates when available and falls back to published year
|
* - `publishedDate` - uses full dates when available and falls back to published year
|
||||||
* - `random` - day-seeded pseudorandom based on book id and user id
|
* - `random` - pseudorandom, seeded by `BookQuery.randomSeed` so one browsing session
|
||||||
|
* keeps a stable order across pages while a new visit reshuffles
|
||||||
*
|
*
|
||||||
* Fields marked "per-user, correlated subquery" require an authenticated userId and
|
* Fields marked "per-user, correlated subquery" require an authenticated userId and
|
||||||
* execute a subquery per result row; they are slower on large result sets.
|
* execute a subquery per result row; they are slower on large result sets.
|
||||||
@@ -383,4 +384,24 @@ export type BookQuery = {
|
|||||||
pagination: { page: number; size: number };
|
pagination: { page: number; size: number };
|
||||||
collapseSeries?: boolean;
|
collapseSeries?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
/**
|
||||||
|
* Shuffle seed for the `random` sort field. Every page of one listing must send the same
|
||||||
|
* value or paging would draw from a different shuffle and repeat or skip books; a new value
|
||||||
|
* reshuffles. Ignored when no sort tier is `random`. Callers that omit it get a per-user
|
||||||
|
* order that only changes daily.
|
||||||
|
*/
|
||||||
|
randomSeed?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Upper bound for `BookQuery.randomSeed`, chosen so the seed always fits a signed 32-bit int. */
|
||||||
|
export const MAX_RANDOM_SORT_SEED = 2_147_483_647;
|
||||||
|
|
||||||
|
/** True when any sort tier shuffles, which is what makes `BookQuery.randomSeed` meaningful. */
|
||||||
|
export function hasRandomSort(sort: SortSpec[] | undefined): boolean {
|
||||||
|
return sort?.some((spec) => spec.field === "random") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh shuffle seed. One per browsing session, reused for every page of that session. */
|
||||||
|
export function createRandomSortSeed(): number {
|
||||||
|
return Math.floor(Math.random() * (MAX_RANDOM_SORT_SEED + 1));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1846,6 +1846,12 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the supplied shuffle seed for the collapsed random sort', () => {
|
||||||
|
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: 4242 });
|
||||||
|
|
||||||
|
expect(result).toContain(`md5(r.id::text || ':' || 4242::text) ASC`);
|
||||||
|
});
|
||||||
|
|
||||||
it('joins multiple sort parts with comma', () => {
|
it('joins multiple sort parts with comma', () => {
|
||||||
const result = BookQueryBuilder.buildCollapseOrderBy(
|
const result = BookQueryBuilder.buildCollapseOrderBy(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
|
|||||||
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from '../../common/utils/accent-insensitive-search.utils';
|
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||||
import { compareSeriesIndexSql, seriesIndexSortKey, seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
|
import { compareSeriesIndexSql, seriesIndexSortKey, seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
|
||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
import { BookSortBuilder, customMetadataValueColumn, type BookSortContext } from './book-sort-builder.service';
|
import { BookSortBuilder, customMetadataValueColumn, resolveRandomSortSeed, type BookSortContext } from './book-sort-builder.service';
|
||||||
import {
|
import {
|
||||||
audiobookProgress,
|
audiobookProgress,
|
||||||
authors,
|
authors,
|
||||||
@@ -1057,7 +1057,7 @@ export class BookQueryBuilder {
|
|||||||
return node.rules.some((r) => BookQueryBuilder.hasSeriesSelectionFilter(r));
|
return node.rules.some((r) => BookQueryBuilder.hasSeriesSelectionFilter(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
static buildCollapseOrderBy(sort: SortSpec[], userId: number, customFieldTypes?: CustomMetadataFieldTypeMap): string {
|
static buildCollapseOrderBy(sort: SortSpec[], userId: number, customFieldTypes?: CustomMetadataFieldTypeMap, context?: BookSortContext): string {
|
||||||
if (sort.length === 0) return 'sort_title ASC NULLS LAST, r.id ASC';
|
if (sort.length === 0) return 'sort_title ASC NULLS LAST, r.id ASC';
|
||||||
|
|
||||||
if (!Number.isSafeInteger(userId)) throw new BadRequestException('Invalid userId for collapse order');
|
if (!Number.isSafeInteger(userId)) throw new BadRequestException('Invalid userId for collapse order');
|
||||||
@@ -1143,9 +1143,8 @@ export class BookQueryBuilder {
|
|||||||
parts.push(`(SELECT ubs.started_at FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}) ${D} NULLS LAST`);
|
parts.push(`(SELECT ubs.started_at FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}) ${D} NULLS LAST`);
|
||||||
break;
|
break;
|
||||||
case 'random': {
|
case 'random': {
|
||||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
const seed = Math.trunc(resolveRandomSortSeed(context, userId));
|
||||||
const scopedSeed = daySeed + userId;
|
parts.push(`md5(r.id::text || ':' || ${seed}::text) ${D}`);
|
||||||
parts.push(`md5(r.id::text || ':' || ${scopedSeed}::text) ${D}`);
|
|
||||||
parts.push(`r.id ${D}`);
|
parts.push(`r.id ${D}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ describe('BookSortBuilder', () => {
|
|||||||
expect(raw).toHaveBeenNthCalledWith(2, 'DESC');
|
expect(raw).toHaveBeenNthCalledWith(2, 'DESC');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the supplied shuffle seed instead of the daily fallback', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2025-01-15T00:00:00Z'));
|
||||||
|
|
||||||
|
const result = service.build([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: 12345 });
|
||||||
|
|
||||||
|
expect(result[0]?.values[1]).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the daily seed when the supplied shuffle seed is out of range', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2025-01-15T00:00:00Z'));
|
||||||
|
|
||||||
|
const result = service.build([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: -1 });
|
||||||
|
|
||||||
|
expect(result[0]?.values[1]).toBe(Math.floor(new Date('2025-01-15T00:00:00Z').getTime() / 86_400_000) + 7);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws for readProgress sort without userId', () => {
|
it('throws for readProgress sort without userId', () => {
|
||||||
expect(() => service.build([{ field: 'readProgress', dir: 'asc' }])).toThrow(
|
expect(() => service.build([{ field: 'readProgress', dir: 'asc' }])).toThrow(
|
||||||
new BadRequestException('readProgress sort requires an authenticated user'),
|
new BadRequestException('readProgress sort requires an authenticated user'),
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AnyColumn, SQL, sql } from 'drizzle-orm';
|
import { AnyColumn, SQL, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { parseCustomSortFieldId } from '@bookorbit/types';
|
import { MAX_RANDOM_SORT_SEED, parseCustomSortFieldId } from '@bookorbit/types';
|
||||||
import type { CustomMetadataFieldType, CustomMetadataFieldTypeMap, SortField, SortSpec } from '@bookorbit/types';
|
import type { CustomMetadataFieldType, CustomMetadataFieldTypeMap, SortField, SortSpec } from '@bookorbit/types';
|
||||||
import { bookMetadata, books, collectionBooks } from '../../db/schema';
|
import { bookMetadata, books, collectionBooks } from '../../db/schema';
|
||||||
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
|
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
|
||||||
|
|
||||||
export type BookSortContext = {
|
export type BookSortContext = {
|
||||||
defaultCollectionId?: number;
|
defaultCollectionId?: number;
|
||||||
|
/** Seed for the `random` sort field. See `resolveRandomSortSeed`. */
|
||||||
|
randomSeed?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsing clients send a seed so every page uses the same shuffle and a fresh visit reshuffles.
|
||||||
|
* Other callers fall back to a per-user daily seed to keep pagination coherent within a day.
|
||||||
|
*/
|
||||||
|
export function resolveRandomSortSeed(context: BookSortContext | undefined, userId: number | undefined): number {
|
||||||
|
const seed = context?.randomSeed;
|
||||||
|
if (seed !== undefined && Number.isSafeInteger(seed) && seed >= 0 && seed <= MAX_RANDOM_SORT_SEED) return seed;
|
||||||
|
return Math.floor(Date.now() / 86_400_000) + (userId ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
const CUSTOM_VALUE_COLUMNS: Record<CustomMetadataFieldType, string> = {
|
const CUSTOM_VALUE_COLUMNS: Record<CustomMetadataFieldType, string> = {
|
||||||
text: 'value_text',
|
text: 'value_text',
|
||||||
url: 'value_text',
|
url: 'value_text',
|
||||||
@@ -125,9 +137,8 @@ export class BookSortBuilder {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'random': {
|
case 'random': {
|
||||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
const seed = resolveRandomSortSeed(context, userId);
|
||||||
const scopedSeed = daySeed + (userId ?? 0);
|
result.push(sql`md5(${books.id}::text || ':' || ${seed}::text) ${sql.raw(D)}`);
|
||||||
result.push(sql`md5(${books.id}::text || ':' || ${scopedSeed}::text) ${sql.raw(D)}`);
|
|
||||||
result.push(sql`${books.id} ${sql.raw(D)}`);
|
result.push(sql`${books.id} ${sql.raw(D)}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,6 +760,7 @@ export class BookRepository {
|
|||||||
userId: number;
|
userId: number;
|
||||||
customFieldTypes?: CustomMetadataFieldTypeMap;
|
customFieldTypes?: CustomMetadataFieldTypeMap;
|
||||||
defaultCollectionId?: number;
|
defaultCollectionId?: number;
|
||||||
|
randomSeed?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -824,7 +825,7 @@ export class BookRepository {
|
|||||||
throw new BadRequestException('Invalid default collection id');
|
throw new BadRequestException('Invalid default collection id');
|
||||||
}
|
}
|
||||||
const whereFragment = this.visibleWhere(where);
|
const whereFragment = this.visibleWhere(where);
|
||||||
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes);
|
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
|
||||||
// The collectionOrder branch of the order by names sort_collection_position, so the column has
|
// The collectionOrder branch of the order by names sort_collection_position, so the column has
|
||||||
// to exist even for the library and smart scope queries that can never sort on it.
|
// to exist even for the library and smart scope queries that can never sort on it.
|
||||||
const collectionPosition =
|
const collectionPosition =
|
||||||
@@ -1167,11 +1168,12 @@ export class BookRepository {
|
|||||||
userId: number;
|
userId: number;
|
||||||
maxBuckets: number;
|
maxBuckets: number;
|
||||||
customFieldTypes?: CustomMetadataFieldTypeMap;
|
customFieldTypes?: CustomMetadataFieldTypeMap;
|
||||||
|
randomSeed?: number;
|
||||||
}): Promise<JumpBucketsResponse> {
|
}): Promise<JumpBucketsResponse> {
|
||||||
const source = collapsedDiscreteSourceParts(opts.field, opts.userId);
|
const source = collapsedDiscreteSourceParts(opts.field, opts.userId);
|
||||||
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
|
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
|
||||||
const whereFragment = this.visibleWhere(opts.where);
|
const whereFragment = this.visibleWhere(opts.where);
|
||||||
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId, opts.customFieldTypes);
|
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
|
||||||
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
|
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
|
||||||
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
|
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
|
||||||
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
|
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
|
||||||
|
|||||||
@@ -2742,7 +2742,7 @@ describe('BookService', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
|
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
|
||||||
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42, undefined);
|
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42, undefined, {});
|
||||||
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(
|
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }),
|
expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }),
|
||||||
);
|
);
|
||||||
@@ -4480,7 +4480,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
const result = await service.executeBooksQuery(12, undefined, query);
|
const result = await service.executeBooksQuery(12, undefined, query);
|
||||||
|
|
||||||
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined);
|
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined, {});
|
||||||
expect(bookRepo.findCards).toHaveBeenCalledWith({
|
expect(bookRepo.findCards).toHaveBeenCalledWith({
|
||||||
where: undefined,
|
where: undefined,
|
||||||
orderBy: 'order-by',
|
orderBy: 'order-by',
|
||||||
@@ -4605,7 +4605,7 @@ describe('BookService', () => {
|
|||||||
await service.executeBooksQuery(12, undefined, query);
|
await service.executeBooksQuery(12, undefined, query);
|
||||||
|
|
||||||
expect(customMetadataService.getActiveFieldTypes).toHaveBeenCalledWith([7]);
|
expect(customMetadataService.getActiveFieldTypes).toHaveBeenCalledWith([7]);
|
||||||
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, fieldTypes);
|
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, fieldTypes, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not query custom metadata field types when no custom sort is used', async () => {
|
it('does not query custom metadata field types when no custom sort is used', async () => {
|
||||||
|
|||||||
@@ -1095,6 +1095,7 @@ export class BookService {
|
|||||||
userId,
|
userId,
|
||||||
customFieldTypes,
|
customFieldTypes,
|
||||||
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
|
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
|
||||||
|
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
|
||||||
});
|
});
|
||||||
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
|
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
|
||||||
const result = {
|
const result = {
|
||||||
@@ -1122,10 +1123,10 @@ export class BookService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy =
|
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
|
||||||
options?.defaultCollectionId !== undefined
|
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
|
||||||
? this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, { defaultCollectionId: options.defaultCollectionId })
|
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
|
||||||
: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes);
|
});
|
||||||
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
|
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
|
||||||
await this.bookRepo.findCards({
|
await this.bookRepo.findCards({
|
||||||
where,
|
where,
|
||||||
@@ -1164,7 +1165,9 @@ export class BookService {
|
|||||||
|
|
||||||
async executeBookIdsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<number[]> {
|
async executeBookIdsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<number[]> {
|
||||||
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
|
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
|
||||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes);
|
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
|
||||||
|
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
|
||||||
|
});
|
||||||
return this.bookRepo.findCardIds({
|
return this.bookRepo.findCardIds({
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
@@ -1231,11 +1234,12 @@ export class BookService {
|
|||||||
// rows inside a bucket, so they can reference custom fields. The temporal path
|
// rows inside a bucket, so they can reference custom fields. The temporal path
|
||||||
// never applies the full sort, which is why this only matters here.
|
// never applies the full sort, which is why this only matters here.
|
||||||
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
|
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
|
||||||
|
const randomSeedOption = query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {};
|
||||||
response = shouldCollapse
|
response = shouldCollapse
|
||||||
? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort, customFieldTypes })
|
? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort, customFieldTypes, ...randomSeedOption })
|
||||||
: await this.bookRepo.findJumpBuckets({
|
: await this.bookRepo.findJumpBuckets({
|
||||||
...discreteOpts,
|
...discreteOpts,
|
||||||
orderBy: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes),
|
orderBy: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, randomSeedOption),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ describe('BookQueryPipe', () => {
|
|||||||
expect(result.filter).toBeDefined();
|
expect(result.filter).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a shuffle seed', () => {
|
||||||
|
const result = pipe.transform({ sort: [{ field: 'random', dir: 'asc' }], randomSeed: 987654 });
|
||||||
|
expect(result.randomSeed).toBe(987654);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the shuffle seed undefined when the caller omits it', () => {
|
||||||
|
expect(pipe.transform({ sort: [{ field: 'random', dir: 'asc' }] }).randomSeed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException for an out-of-range shuffle seed', () => {
|
||||||
|
expect(() => pipe.transform({ randomSeed: 2_147_483_648 })).toThrow(BadRequestException);
|
||||||
|
expect(() => pipe.transform({ randomSeed: -1 })).toThrow(BadRequestException);
|
||||||
|
expect(() => pipe.transform({ randomSeed: 1.5 })).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws BadRequestException for invalid sort field', () => {
|
it('throws BadRequestException for invalid sort field', () => {
|
||||||
expect(() => pipe.transform({ sort: [{ field: 'unknownField', dir: 'asc' }] })).toThrow(BadRequestException);
|
expect(() => pipe.transform({ sort: [{ field: 'unknownField', dir: 'asc' }] })).toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { isSortField, type BookQuery, type JumpBucketsQuery, type SortField } from '@bookorbit/types';
|
import { MAX_RANDOM_SORT_SEED, isSortField, type BookQuery, type JumpBucketsQuery, type SortField } from '@bookorbit/types';
|
||||||
|
|
||||||
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../../common/constants/pagination.constants';
|
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../../common/constants/pagination.constants';
|
||||||
import { groupRuleSchema } from '../utils/group-rule.validator';
|
import { groupRuleSchema } from '../utils/group-rule.validator';
|
||||||
@@ -12,6 +12,7 @@ const bookQuerySchema = z.object({
|
|||||||
collapseSeries: z.boolean().optional(),
|
collapseSeries: z.boolean().optional(),
|
||||||
filter: groupRuleSchema(5).optional(),
|
filter: groupRuleSchema(5).optional(),
|
||||||
q: z.string().max(200).optional(),
|
q: z.string().max(200).optional(),
|
||||||
|
randomSeed: z.number().int().min(0).max(MAX_RANDOM_SORT_SEED).optional(),
|
||||||
sort: z
|
sort: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user