mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): expand book request discovery
Allow request searches to browse broader provider result sets, rerun queries by exact author, and submit unmatched titles for approval. Advertise the optional limit so older clients retain their existing contract. Closes #1363
This commit is contained in:
@@ -266,6 +266,28 @@ describe('RequestSearchPanel provider sources', () => {
|
||||
]
|
||||
})
|
||||
|
||||
it('asks every provider for a browsing page of results', async () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.find('#request-author').setValue('Terry Pratchett')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(state.search).toHaveBeenLastCalledWith({ title: undefined, author: 'Terry Pratchett', mediaKind: 'ebook', resultsPerProvider: 20 })
|
||||
})
|
||||
|
||||
it('searches for an author alone when their name on a result is pressed', async () => {
|
||||
const scrollIntoView = vi.fn<() => void>()
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
|
||||
const wrapper = await render()
|
||||
await wrapper.find('#request-title').setValue('One Dark Window')
|
||||
|
||||
await wrapper.find('button[aria-label="Show books by Rachel Gillig"]').trigger('click')
|
||||
|
||||
expect((wrapper.find('#request-title').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.find('#request-author').element as HTMLInputElement).value).toBe('Rachel Gillig')
|
||||
expect(state.search).toHaveBeenLastCalledWith({ title: undefined, author: 'Rachel Gillig', mediaKind: 'ebook', resultsPerProvider: 20 })
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
})
|
||||
|
||||
it('shows every contributing provider as an icon or initials badge', async () => {
|
||||
const wrapper = await render()
|
||||
const sources = wrapper.get('[role="img"]')
|
||||
@@ -685,6 +707,92 @@ describe('RequestSearchPanel free-text fallback', () => {
|
||||
expect(wrapper.text()).not.toContain(ACTION)
|
||||
})
|
||||
|
||||
describe('for a requester whose requests go to an approver', () => {
|
||||
const REQUEST_ANYWAY = 'Request anyway'
|
||||
|
||||
beforeEach(() => {
|
||||
state.hasPermission.mockImplementation((permission: string) => permission === Permission.BookRequestAccess)
|
||||
})
|
||||
|
||||
async function requestAnyway(wrapper: Awaited<ReturnType<typeof render>>) {
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === REQUEST_ANYWAY)!
|
||||
.trigger('click')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
it('offers the typed title as a request once a provider search comes back empty', async () => {
|
||||
const wrapper = await typeTitle(await render())
|
||||
|
||||
expect(wrapper.text()).toContain(REQUEST_ANYWAY)
|
||||
expect(wrapper.text()).toContain('A note for the approver under More options helps them find the right book.')
|
||||
})
|
||||
|
||||
it('does not offer it before a search has been run', async () => {
|
||||
state.hasSearched.value = false
|
||||
|
||||
const wrapper = await typeTitle(await render())
|
||||
expect(wrapper.text()).not.toContain(REQUEST_ANYWAY)
|
||||
})
|
||||
|
||||
it('leaves out the approver for a requester whose requests approve themselves', async () => {
|
||||
state.hasPermission.mockImplementation(
|
||||
(permission: string) => permission === Permission.BookRequestAccess || permission === Permission.BookRequestAutoApprove,
|
||||
)
|
||||
|
||||
const wrapper = await typeTitle(await render())
|
||||
expect(wrapper.text()).toContain(REQUEST_ANYWAY)
|
||||
expect(wrapper.text()).not.toContain('approver under More options')
|
||||
})
|
||||
|
||||
it('says what the queue already has, before anything is created', async () => {
|
||||
state.checkFreeText.mockResolvedValue({ ownedBookId: null, existingRequestId: 5, alreadySubscribed: false })
|
||||
|
||||
const wrapper = await typeTitle(await render())
|
||||
await settleHint(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('Already requested by someone else')
|
||||
expect(state.submitFreeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends it with the note to the approver and stays on the search', async () => {
|
||||
state.submitFreeText.mockResolvedValue({ request: { id: 32, userId: 7, status: 'pending' }, subscribed: false })
|
||||
|
||||
const wrapper = await typeTitle(await render())
|
||||
await wrapper.find('#request-note').setValue('The 1998 paperback')
|
||||
await requestAnyway(wrapper)
|
||||
|
||||
expect(state.submitFreeText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'An Untracked Novel' }),
|
||||
expect.objectContaining({ selfServe: false, note: 'The 1998 paperback' }),
|
||||
)
|
||||
expect(state.push).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('submitted')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('requests both media as typed in pair mode', async () => {
|
||||
state.pairMode.value = true
|
||||
state.submitPairFreeText.mockResolvedValue({
|
||||
ebook: { request: { id: 40, userId: 7, status: 'pending' }, subscribed: false },
|
||||
audiobook: { request: { id: 41, userId: 7, status: 'pending' }, subscribed: false },
|
||||
})
|
||||
|
||||
try {
|
||||
const wrapper = await typeTitle(await render())
|
||||
await requestAnyway(wrapper)
|
||||
|
||||
expect(state.submitPairFreeText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'An Untracked Novel' }),
|
||||
expect.objectContaining({ selfServe: false }),
|
||||
)
|
||||
expect(state.push).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
state.pairMode.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer it before a search has been run', async () => {
|
||||
state.hasSearched.value = false
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isBookRequestFulfiller,
|
||||
isGrabbableBookRequestStatus,
|
||||
MAX_BOOK_REQUEST_SEARCH_ISBNS,
|
||||
METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER,
|
||||
Permission,
|
||||
} from '@bookorbit/types'
|
||||
import type {
|
||||
@@ -120,6 +121,8 @@ const FORMATS_BY_MEDIA = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
|
||||
|
||||
const title = ref('')
|
||||
const author = ref('')
|
||||
const searchCard = ref<HTMLElement | null>(null)
|
||||
const authorInput = ref<HTMLInputElement | null>(null)
|
||||
const language = ref<string | null>(null)
|
||||
/**
|
||||
* A preference, not a filter: a release in another format still appears, it simply scores lower.
|
||||
@@ -259,9 +262,24 @@ async function runSearch() {
|
||||
title: title.value.trim() || undefined,
|
||||
author: author.value.trim() || undefined,
|
||||
...(pairMode.value ? { mediaKinds: [...BOOK_REQUEST_MEDIA_KINDS] } : { mediaKind: mediaKind.value }),
|
||||
// Looking for something to request is browsing, so an author's search should show their books, not a best match.
|
||||
resultsPerProvider: METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* An author on a result is a way to see more of their books: the search becomes that author alone.
|
||||
* The list the name sat in is replaced, so the query comes into view, and keyboard focus moves to it.
|
||||
* A tap leaves focus alone, since focusing a field would open the on-screen keyboard over the results.
|
||||
*/
|
||||
async function searchAuthor(name: string, event: MouseEvent) {
|
||||
title.value = ''
|
||||
author.value = name
|
||||
searchCard.value?.scrollIntoView({ block: 'nearest' })
|
||||
if (event.detail === 0) authorInput.value?.focus({ preventScroll: true })
|
||||
await runSearch()
|
||||
}
|
||||
|
||||
const showCacheNotice = computed(() => hasSearched.value && cacheSummary.value.cachedCount > 0)
|
||||
// Offered after every finished search: a provider whose saved answer was "nothing" leaves no result to mark.
|
||||
const showSearchFresh = computed(() => hasSearched.value && !isStreaming.value && !showCacheNotice.value)
|
||||
@@ -568,21 +586,29 @@ function pairMediumLabel(kind: BookRequestMediaKind): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Only once a provider search has come back empty, and only for somebody who will pick the release
|
||||
* themselves. An approver handed a row that says nothing but a typed string has no way to tell
|
||||
* whether it is the book that was meant.
|
||||
* Only once a provider search has come back empty. Somebody who picks the release themselves goes
|
||||
* straight to the indexers with what they typed. Anyone else may still request it as typed: the
|
||||
* provider catalogues are not the limit of what an indexer has, and the note for the approver is
|
||||
* where they say which book they mean, since the row carries no cover or ISBN to tell by.
|
||||
*/
|
||||
const canSearchIndexersManually = computed(() => selfFulfils.value && hasSearched.value && title.value.trim() !== '')
|
||||
const showFreeTextOption = computed(() => canSearchIndexersManually.value && groups.value.length === 0)
|
||||
const showManualSearchOverride = computed(() => canSearchIndexersManually.value && groups.value.length > 0)
|
||||
const showRequestAnyway = computed(() => !selfFulfils.value && hasSearched.value && title.value.trim() !== '' && groups.value.length === 0)
|
||||
const offersTypedRequest = computed(() => showFreeTextOption.value || showRequestAnyway.value)
|
||||
|
||||
const freeTextBusy = ref(false)
|
||||
const freeTextNotice = ref<string | null>(null)
|
||||
|
||||
/** One sentence either way: the indexer fallback is a longer answer to the same question. */
|
||||
const noResultsHint = computed(() =>
|
||||
showFreeTextOption.value ? t('bookRequests.search.noResultsIndexerHint', { title: title.value.trim() }) : t('bookRequests.search.noResultsHint'),
|
||||
)
|
||||
/** One sentence either way: the typed-request fallback is a longer answer to the same question. */
|
||||
const noResultsHint = computed(() => {
|
||||
const typed = { title: title.value.trim() }
|
||||
if (showFreeTextOption.value) return t('bookRequests.search.noResultsIndexerHint', typed)
|
||||
if (showRequestAnyway.value) {
|
||||
return autoApproves.value ? t('bookRequests.search.noResultsRequestAutoHint', typed) : t('bookRequests.search.noResultsRequestHint', typed)
|
||||
}
|
||||
return t('bookRequests.search.noResultsHint')
|
||||
})
|
||||
|
||||
/**
|
||||
* What the library and the queue already know about the typed text, shown before anything is
|
||||
@@ -598,11 +624,11 @@ const FREE_TEXT_HINT_DEBOUNCE_MS = 350
|
||||
let hintHandle: ReturnType<typeof setTimeout> | null = null
|
||||
let hintGeneration = 0
|
||||
|
||||
watch([showFreeTextOption, title, author, mediaKind], () => {
|
||||
watch([offersTypedRequest, title, author, mediaKind], () => {
|
||||
freeTextNotice.value = null
|
||||
hintGeneration++
|
||||
if (hintHandle !== null) clearTimeout(hintHandle)
|
||||
if (!showFreeTextOption.value) return
|
||||
if (!offersTypedRequest.value) return
|
||||
|
||||
const generation = hintGeneration
|
||||
hintHandle = setTimeout(async () => {
|
||||
@@ -650,19 +676,24 @@ function releasesQuery(searchIsbn?: string | null): LocationQuery {
|
||||
return searchIsbn ? { ...carried, isbn: searchIsbn } : { ...carried, search: 'title-author' }
|
||||
}
|
||||
|
||||
async function startFreeTextSearch() {
|
||||
/**
|
||||
* Requests what was typed, with no provider record behind it. A self-fulfiller goes on to pick the
|
||||
* release; anyone else leaves it to the approver, or to automation when their requests auto-approve,
|
||||
* both of which search the indexers by the same title and author.
|
||||
*/
|
||||
async function submitTypedRequest(selfServe: boolean) {
|
||||
freeTextBusy.value = true
|
||||
try {
|
||||
if (pairMode.value) {
|
||||
const pair = await submitPairFreeText(
|
||||
{ title: title.value, author: author.value },
|
||||
{ language: language.value, selfServe: true, note: note.value.trim() || null, ...pairHalves() },
|
||||
{ language: language.value, selfServe, note: note.value.trim() || null, ...pairHalves() },
|
||||
)
|
||||
if (!pair) {
|
||||
toast.error(submitFailureText(lastFailure.value, t) ?? t('bookRequests.search.submitFailed'))
|
||||
return
|
||||
}
|
||||
await afterPairSubmit(pair, true)
|
||||
await afterPairSubmit(pair, selfServe)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -672,7 +703,7 @@ async function startFreeTextSearch() {
|
||||
targetLibraryId: targetLibraryId.value,
|
||||
targetFolderId: targetFolderId.value,
|
||||
language: language.value,
|
||||
selfServe: true,
|
||||
selfServe,
|
||||
...requestExtras(),
|
||||
},
|
||||
)
|
||||
@@ -680,12 +711,20 @@ async function startFreeTextSearch() {
|
||||
toast.error(submitFailureText(lastFailure.value, t) ?? t('bookRequests.search.submitFailed'))
|
||||
return
|
||||
}
|
||||
await afterSubmit(result, true)
|
||||
await afterSubmit(result, selfServe)
|
||||
} finally {
|
||||
freeTextBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startFreeTextSearch() {
|
||||
await submitTypedRequest(true)
|
||||
}
|
||||
|
||||
async function requestAnyway() {
|
||||
await submitTypedRequest(false)
|
||||
}
|
||||
|
||||
async function requestGroup(group: CandidateGroup) {
|
||||
const choice = recommendedSearchChoice(group)
|
||||
await requestGroupChoice(group, choice?.candidate ?? group.candidate, choice?.isbn ?? null, choosesRelease.value)
|
||||
@@ -842,7 +881,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
because a transparent input reads as a caption, and the secondary controls sit in a strip
|
||||
attached to the same card rather than floating on their own line below it.
|
||||
-->
|
||||
<div class="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div ref="searchCard" class="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div class="flex flex-col gap-2.5 p-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div
|
||||
class="flex h-9 w-full items-center gap-2 rounded-md border border-input bg-background px-3 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/40 sm:w-auto sm:min-w-[13rem] sm:flex-[2]"
|
||||
@@ -865,6 +904,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
<label for="request-author" class="sr-only">{{ t('bookRequests.search.authorLabel') }}</label>
|
||||
<input
|
||||
id="request-author"
|
||||
ref="authorInput"
|
||||
v-model="author"
|
||||
type="text"
|
||||
class="h-full w-full min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none"
|
||||
@@ -1098,9 +1138,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
<!--
|
||||
One card, on the same edges as the search card above it. The escape hatch is the answer to an
|
||||
empty search rather than a second notice about it, so it shares this block instead of adding a
|
||||
second one indented to its own left edge. It is offered only to somebody who can fulfil the
|
||||
request themselves: without the picker on the other side it would create a request an approver
|
||||
could not identify.
|
||||
second one indented to its own left edge.
|
||||
-->
|
||||
<div
|
||||
v-else-if="hasSearched && groups.length === 0"
|
||||
@@ -1123,6 +1161,16 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
>
|
||||
{{ t('bookRequests.search.freeTextAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="showRequestAnyway"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full shrink-0 sm:w-auto"
|
||||
:disabled="freeTextBusy || freeTextDestinationMissing"
|
||||
@click="requestAnyway"
|
||||
>
|
||||
{{ t('bookRequests.search.requestAnywayAction') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="interruptedMessage" role="status" class="text-sm text-muted-foreground">{{ interruptedMessage }}</p>
|
||||
@@ -1162,7 +1210,20 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="truncate text-[15px] font-semibold text-foreground">{{ group.title }}</p>
|
||||
<p v-if="group.authors.length" class="truncate text-sm text-muted-foreground">{{ group.authors.join(', ') }}</p>
|
||||
<p v-if="group.authors.length" class="truncate text-sm text-muted-foreground">
|
||||
<template v-for="(name, index) in group.authors" :key="name">
|
||||
<span v-if="index > 0">, </span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline rounded-sm underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-current focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
:title="t('bookRequests.search.searchAuthor', { author: name })"
|
||||
:aria-label="t('bookRequests.search.searchAuthor', { author: name })"
|
||||
@click="searchAuthor(name, $event)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="tabular-nums">{{ group.publishedYear ?? t('bookRequests.search.yearUnknown') }}</span>
|
||||
|
||||
@@ -152,6 +152,23 @@ describe('useMetadataSearch', () => {
|
||||
expect(state.isStreaming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('asks for more results per provider only when the search says so, and keeps asking on a fresh search', async () => {
|
||||
apiMock.mockResolvedValue({ ok: false })
|
||||
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useMetadataSearch())!
|
||||
await state.search({ author: 'Terry Pratchett', resultsPerProvider: 20 })
|
||||
await state.searchFresh()
|
||||
await state.search({ title: 'Dune' })
|
||||
scope.stop()
|
||||
|
||||
expect(apiMock.mock.calls.map(([input]) => String(input))).toEqual([
|
||||
'/api/v1/metadata-fetch/stream?author=Terry+Pratchett&resultsPerProvider=20',
|
||||
'/api/v1/metadata-fetch/stream?author=Terry+Pratchett&cache=refresh&resultsPerProvider=20',
|
||||
'/api/v1/metadata-fetch/stream?title=Dune',
|
||||
])
|
||||
})
|
||||
|
||||
it('requests the sole available provider when no provider filter is selected', async () => {
|
||||
apiMock.mockResolvedValue({ ok: false })
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SearchParams {
|
||||
providers?: MetadataProviderKey[]
|
||||
/** `refresh` skips saved answers and asks every provider live. */
|
||||
cache?: MetadataCacheRequestPolicy
|
||||
/** Ranked results each provider may return, for a search that browses rather than matches one book. */
|
||||
resultsPerProvider?: number
|
||||
}
|
||||
|
||||
export function useMetadataSearch() {
|
||||
@@ -83,6 +85,7 @@ export function useMetadataSearch() {
|
||||
const requestedProviders = params.providers ?? (selectedProviders.value.length ? selectedProviders.value : onlyProvider ? [onlyProvider.key] : [])
|
||||
if (requestedProviders.length) query.set('providers', requestedProviders.join(','))
|
||||
if (params.cache === METADATA_CACHE_REFRESH) query.set('cache', METADATA_CACHE_REFRESH)
|
||||
if (params.resultsPerProvider != null) query.set('resultsPerProvider', String(params.resultsPerProvider))
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
@@ -8124,6 +8124,10 @@
|
||||
"noResultsHint": "Try a different title or author.",
|
||||
"noResultsIndexerHint": "Try a different title or author, or search the indexers for \"{title}\" and pick a release yourself.",
|
||||
"freeTextAction": "Search indexers",
|
||||
"noResultsRequestHint": "Try a different title or author, or request \"{title}\" as typed. A note for the approver under More options helps them find the right book.",
|
||||
"noResultsRequestAutoHint": "Try a different title or author, or request \"{title}\" as typed.",
|
||||
"requestAnywayAction": "Request anyway",
|
||||
"searchAuthor": "Show books by {author}",
|
||||
"manualSearchAction": "Search indexers manually",
|
||||
"yearUnknown": "Year unknown",
|
||||
"sourceCount": "{count, plural, one {# source} other {# sources}}",
|
||||
|
||||
Reference in New Issue
Block a user