feat(metadata): add empty-field details and optional auto-search

Let users inspect missing fields without launching provider searches.
Keep metadata searches manual by default and expose a remembered
opt-in beside Search.
This commit is contained in:
neon
2026-10-07 18:38:07 +00:00
parent 3ef4a22c37
commit 8fef9d1633
10 changed files with 273 additions and 28 deletions
@@ -27,6 +27,7 @@ import type {
} from '@bookorbit/types' } from '@bookorbit/types'
import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types' import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types'
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 { api } from '@/lib/api' import { api } from '@/lib/api'
import { metadataScoreColor } from '@/lib/metadata-score-color' import { metadataScoreColor } from '@/lib/metadata-score-color'
import ChipInput from '@/components/ui/ChipInput.vue' import ChipInput from '@/components/ui/ChipInput.vue'
@@ -301,7 +302,6 @@ const emptyFields = computed(() => {
] ]
return checks.filter((check) => !check.filled).map((check) => check.label) return checks.filter((check) => !check.filled).map((check) => check.label)
}) })
const emptyFieldsTitle = computed(() => t('book.detail.editMetadata.emptyFieldsTooltip', { fields: formatList(emptyFields.value) }))
const metadataScore = computed(() => props.book.metadataScore) const metadataScore = computed(() => props.book.metadataScore)
const metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value))) const metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value)))
@@ -871,17 +871,25 @@ function handleCoverChanged(medium: CoverMedium | null) {
</span> </span>
</div> </div>
<button <Popover v-if="emptyFields.length > 0">
v-if="emptyFields.length > 0" <PopoverTrigger as-child>
type="button" <button
class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-dashed border-amber-500/40 bg-amber-500/10 px-2.5 text-xs font-semibold text-amber-600 transition-colors hover:bg-amber-500/15 sm:h-8 dark:text-amber-400" type="button"
:title="emptyFieldsTitle" class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-dashed border-warning/40 bg-warning/10 px-2.5 text-xs font-semibold text-foreground transition-colors hover:bg-warning/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-8"
@click="handleOpenSearch" :aria-label="t('book.detail.editMetadata.emptyFieldsCount', { count: emptyFields.length })"
> >
<TriangleAlert class="size-3.5 shrink-0" aria-hidden="true" /> <TriangleAlert class="size-3.5 shrink-0 text-warning" aria-hidden="true" />
<span>{{ emptyFields.length }}</span> <span>{{ emptyFields.length }}</span>
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span> <span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
</button> </button>
</PopoverTrigger>
<PopoverContent align="start" class="w-64 max-w-[calc(100vw-2rem)] p-3" :aria-label="t('book.detail.editMetadata.emptyFieldsHeading')">
<p class="mb-2 text-sm font-semibold">{{ t('book.detail.editMetadata.emptyFieldsHeading') }}</p>
<ul class="grid gap-1 text-sm text-muted-foreground">
<li v-for="field in emptyFields" :key="field">{{ field }}</li>
</ul>
</PopoverContent>
</Popover>
<div class="flex-1" /> <div class="flex-1" />
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onBeforeUnmount, onMounted, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { X } from '@lucide/vue' import { SlidersHorizontal, X } from '@lucide/vue'
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { getBookMediaProfile } from '@bookorbit/types' import { getBookMediaProfile } from '@bookorbit/types'
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types' import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch' import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import { useMetadataSearchPreferences } from '../../../composables/useMetadataSearchPreferences'
import { useCoverVersions } from '../../../composables/useCoverVersions' import { useCoverVersions } from '../../../composables/useCoverVersions'
import type { MetadataDiffApply } from '../../../composables/useMetadataDiff' import type { MetadataDiffApply } from '../../../composables/useMetadataDiff'
import type { SecondCoverInput } from '../../../composables/useSecondCoverRow' import type { SecondCoverInput } from '../../../composables/useSecondCoverRow'
@@ -22,6 +24,8 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const optionsId = useId()
const { autoSearchOnOpen } = useMetadataSearchPreferences()
const { coverUrl } = useCoverVersions() const { coverUrl } = useCoverVersions()
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO)) const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const isAudiobookSearch = computed(() => getBookMediaProfile(props.book.files).primaryMediaKind === 'audiobook') const isAudiobookSearch = computed(() => getBookMediaProfile(props.book.files).primaryMediaKind === 'audiobook')
@@ -117,10 +121,17 @@ const subtitle = computed(() =>
.join(' \u00b7 '), .join(' \u00b7 '),
) )
let active = true
onBeforeUnmount(() => {
active = false
})
onMounted(async () => { onMounted(async () => {
const shouldSearchOnOpen = autoSearchOnOpen.value
await loadProviders(props.book.id) await loadProviders(props.book.id)
const defaults = searchDefaults.value const defaults = searchDefaults.value
if (defaults.title || defaults.isbn) runMetadataSearch({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' }) if (active && shouldSearchOnOpen && (defaults.title || defaults.isbn))
runMetadataSearch({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' })
}) })
function handleOpenChange(open: boolean) { function handleOpenChange(open: boolean) {
@@ -221,7 +232,39 @@ function handleCancel() {
@retry-provider="handleRetry" @retry-provider="handleRetry"
@apply="handleApply" @apply="handleApply"
@cancel="handleCancel" @cancel="handleCancel"
/> >
<template #search-options>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="grid size-8 shrink-0 place-items-center rounded-lg border border-input bg-background text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.editMetadata.searchDrawer.searchOptions')"
>
<SlidersHorizontal class="size-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-72 max-w-[calc(100vw-2rem)] p-3"
:aria-label="t('book.detail.editMetadata.searchDrawer.searchOptions')"
>
<label class="flex cursor-pointer items-start gap-2 text-sm text-foreground">
<input
v-model="autoSearchOnOpen"
type="checkbox"
class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-describedby="`${optionsId}-hint`"
/>
{{ t('book.detail.editMetadata.searchDrawer.autoSearchOnOpen') }}
</label>
<p :id="`${optionsId}-hint`" class="mt-2 text-xs text-muted-foreground">
{{ t('book.detail.editMetadata.searchDrawer.autoSearchOnOpenHint') }}
</p>
</PopoverContent>
</Popover>
</template>
</MetadataMatchWorkspace>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</template> </template>
@@ -153,6 +153,36 @@ describe('EditMetadataTab cover tiles', () => {
}) })
}) })
it('opens a popover listing only empty fields without opening metadata search', async () => {
const wrapper = mountTab(makeBook({ publisher: 'Ace', publishedYear: 1965 }))
document.body.append(wrapper.element)
await flushPromises()
const badge = wrapper.get('[aria-label="book.detail.editMetadata.emptyFieldsCount"]')
await badge.trigger('click')
await flushPromises()
const popover = document.querySelector('[data-slot="popover-content"]')!
expect(popover).not.toBeNull()
expect(Array.from(popover.querySelectorAll('li'), (item) => item.textContent)).toEqual([
'book.detail.editMetadata.languageLabel',
'book.detail.editMetadata.pageCountLabel',
'book.detail.editMetadata.isbn13Label',
'book.detail.editMetadata.isbn10Label',
'book.detail.editMetadata.genresLabel',
'book.detail.editMetadata.tagsLabel',
'book.detail.editMetadata.descriptionLabel',
])
expect(badge.attributes('aria-expanded')).toBe('true')
expect(wrapper.findComponent(MetadataSearchDrawerStub).exists()).toBe(false)
expect(vi.mocked(api).mock.calls.some(([url]) => String(url).includes('/metadata-fetch/stream'))).toBe(false)
await wrapper.setProps({ book: makeBook({ publisher: 'Ace', publishedYear: 1965, language: 'en' }) })
await flushPromises()
expect(popover.textContent).not.toContain('book.detail.editMetadata.languageLabel')
wrapper.unmount()
})
it('enables Save when the only change is an unsaved cover', async () => { it('enables Save when the only change is an unsaved cover', async () => {
const wrapper = mountTab(makeBook()) const wrapper = mountTab(makeBook())
await flushPromises() await flushPromises()
@@ -0,0 +1,132 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookDetail, CoverMedium } from '@bookorbit/types'
import { api } from '@/lib/api'
import MetadataSearchDrawer from '../MetadataSearchDrawer.vue'
vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>() }))
const authUser = ref({ id: 3 })
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: authUser }) }))
const WorkspaceStub = defineComponent({
props: ['searchDefaults', 'hasSearched'],
emits: ['search'],
template: '<div><slot name="search-options" /></div>',
})
const book = {
id: 7,
title: 'Dune',
authors: [{ name: 'Frank Herbert' }],
isbn13: '9780441172719',
files: [],
coverMedia: [],
covers: { ebook: null, audio: null },
} as unknown as BookDetail
function mountDrawer(coverMedia: CoverMedium[] = []) {
return mount(MetadataSearchDrawer, {
props: { book: { ...book, coverMedia }, lockedFields: [] },
global: {
stubs: {
Sheet: { template: '<div><slot /></div>' },
SheetContent: { template: '<div><slot /></div>' },
SheetTitle: { template: '<h2><slot /></h2>' },
SheetDescription: { template: '<p><slot /></p>' },
SheetClose: true,
MetadataMatchShortcuts: true,
MetadataMatchWorkspace: WorkspaceStub,
Popover: { template: '<div><slot /></div>' },
PopoverTrigger: { template: '<div><slot /></div>' },
PopoverContent: { template: '<div><slot /></div>' },
},
},
})
}
function searchCalls() {
return vi.mocked(api).mock.calls.filter(([url]) => String(url).includes('/metadata-fetch/stream'))
}
describe('MetadataSearchDrawer automatic search', () => {
beforeEach(() => {
authUser.value = { id: 3 }
localStorage.clear()
vi.mocked(api).mockReset()
vi.mocked(api).mockImplementation(async (url) =>
String(url).includes('/metadata-fetch/providers')
? new Response(JSON.stringify([{ key: 'google', label: 'Google Books', identifiable: true }]))
: new Response(''),
)
})
it('prefills the query without searching either medium until Search is requested', async () => {
const wrapper = mountDrawer(['ebook', 'audio'])
await flushPromises()
expect(searchCalls()).toHaveLength(0)
expect(wrapper.get('input[type="checkbox"]').element).toHaveProperty('checked', false)
const workspace = wrapper.getComponent(WorkspaceStub)
expect(workspace.props('searchDefaults')).toEqual({ title: 'Dune', author: 'Frank Herbert', isbn: '9780441172719' })
workspace.vm.$emit('search', { title: 'Dune revised', author: 'Frank Herbert', isbn: '' })
await flushPromises()
expect(searchCalls()).toHaveLength(2)
expect(searchCalls()[0]![0]).toContain('title=Dune+revised')
expect(searchCalls()[1]![0]).toContain('mediaKind=audiobook')
wrapper.unmount()
})
it('remembers opting in on this browser and searches on the next opening', async () => {
const wrapper = mountDrawer()
await flushPromises()
await wrapper.get('input[type="checkbox"]').setValue(true)
await flushPromises()
expect(searchCalls()).toHaveLength(0)
wrapper.unmount()
const reopened = mountDrawer()
await flushPromises()
expect(searchCalls()).toHaveLength(1)
expect(searchCalls()[0]![0]).toContain('isbn=9780441172719')
await reopened.get('input[type="checkbox"]').setValue(false)
await flushPromises()
reopened.unmount()
const manual = mountDrawer()
await flushPromises()
expect(searchCalls()).toHaveLength(1)
manual.unmount()
})
it('keeps another account on manual search', async () => {
const wrapper = mountDrawer()
await flushPromises()
await wrapper.get('input[type="checkbox"]').setValue(true)
await flushPromises()
wrapper.unmount()
authUser.value = { id: 4 }
const otherAccount = mountDrawer()
await flushPromises()
expect(otherAccount.get('input[type="checkbox"]').element).toHaveProperty('checked', false)
expect(searchCalls()).toHaveLength(0)
otherAccount.unmount()
})
it('does not start a delayed automatic search after the drawer closes', async () => {
localStorage.setItem('bookorbit:3:metadata-search-auto-start', 'true')
let resolveProviders!: (response: Response) => void
vi.mocked(api).mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveProviders = resolve
}),
)
const wrapper = mountDrawer()
wrapper.unmount()
resolveProviders(new Response('[]'))
await flushPromises()
expect(searchCalls()).toHaveLength(0)
})
})
@@ -100,15 +100,18 @@ defineExpose({ focusTitle })
autocomplete="off" autocomplete="off"
/> />
</label> </label>
<button <div class="flex items-center gap-1.5">
type="submit" <button
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground" type="submit"
:disabled="!canSearch" class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
> :disabled="!canSearch"
<Loader2 v-if="busy" class="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" /> >
<Search v-else class="size-4" aria-hidden="true" /> <Loader2 v-if="busy" class="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />
{{ t('common.search') }} <Search v-else class="size-4" aria-hidden="true" />
</button> {{ t('common.search') }}
</button>
<slot name="search-options" />
</div>
</div> </div>
<div v-if="edited" class="flex items-center justify-between gap-2"> <div v-if="edited" class="flex items-center justify-between gap-2">
<span class="text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.query.edited') }}</span> <span class="text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.query.edited') }}</span>
@@ -116,7 +116,9 @@ defineExpose({ focusQuery })
<template> <template>
<aside class="flex min-h-0 flex-col bg-card/50" :aria-label="t('book.detail.editMetadata.match.results.label')"> <aside class="flex min-h-0 flex-col bg-card/50" :aria-label="t('book.detail.editMetadata.match.results.label')">
<div class="grid gap-3 border-b border-border/60 px-3.5 pt-3.5 pb-3"> <div class="grid gap-3 border-b border-border/60 px-3.5 pt-3.5 pb-3">
<MetadataMatchQuery ref="query" :defaults="searchDefaults" :busy="isStreaming" @search="handleSearch" /> <MetadataMatchQuery ref="query" :defaults="searchDefaults" :busy="isStreaming" @search="handleSearch">
<template #search-options><slot name="search-options" /></template>
</MetadataMatchQuery>
<MetadataMatchSources <MetadataMatchSources
v-if="providers.length" v-if="providers.length"
:providers="providers" :providers="providers"
@@ -51,9 +51,10 @@ const goodreads: MetadataCandidate = {
} }
const collection: MetadataCandidate = { provider: 'google', providerId: 'set', title: 'Artemis Fowl: Books 1-4', authors: ['Eoin Colfer'] } const collection: MetadataCandidate = { provider: 'google', providerId: 'set', title: 'Artemis Fowl: Books 1-4', authors: ['Eoin Colfer'] }
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}) { function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}, slots: Record<string, string> = {}) {
return mount(MetadataMatchWorkspace, { return mount(MetadataMatchWorkspace, {
attachTo: document.body, attachTo: document.body,
slots,
props: { props: {
current, current,
providerIds: { amazon: 'B002KP6DXQ' }, providerIds: { amazon: 'B002KP6DXQ' },
@@ -87,6 +88,14 @@ async function choose(wrapper: VueWrapper, key: string, option: string) {
} }
describe('MetadataMatchWorkspace', () => { describe('MetadataMatchWorkspace', () => {
it('places search options beside the submit button inside the search form', () => {
const wrapper = mountWorkspace({}, { 'search-options': '<button type="button" aria-label="Search options">Options</button>' })
const options = wrapper.get('form[role="search"] [aria-label="Search options"]')
expect(options.element.previousElementSibling).toHaveProperty('type', 'submit')
expect(wrapper.find('header [aria-label="Search options"]').exists()).toBe(false)
wrapper.unmount()
})
it('groups results by how likely they are this book and opens the linked record', () => { it('groups results by how likely they are this book and opens the linked record', () => {
const wrapper = mountWorkspace() const wrapper = mountWorkspace()
@@ -434,7 +434,9 @@ defineExpose({ focusQuery: () => rail.value?.focusQuery() })
@select-field-rules="handleSelectFieldRules" @select-field-rules="handleSelectFieldRules"
@retry-provider="handleRetry" @retry-provider="handleRetry"
@select="select" @select="select"
/> >
<template #search-options><slot name="search-options" /></template>
</MetadataMatchRail>
</div> </div>
<div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'"> <div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'">
<MetadataMatchCompare <MetadataMatchCompare
@@ -0,0 +1,11 @@
import { computed } from 'vue'
import { useStorage } from '@vueuse/core'
import { useAuth } from '@/features/auth/composables/useAuth'
export function useMetadataSearchPreferences() {
const { user } = useAuth()
const storageKey = computed(() => `bookorbit:${user.value?.id ?? 'anonymous'}:metadata-search-auto-start`)
const autoSearchOnOpen = useStorage(storageKey, false)
return { autoSearchOnOpen }
}
+5
View File
@@ -5730,6 +5730,9 @@
}, },
"searchDrawer": { "searchDrawer": {
"searchTitle": "Search Metadata", "searchTitle": "Search Metadata",
"autoSearchOnOpen": "Search automatically when opened",
"autoSearchOnOpenHint": "Uses the book's details next time this panel opens. Remembered for your account in this browser.",
"searchOptions": "Search options",
"compareTitle": "Compare Metadata", "compareTitle": "Compare Metadata",
"searchSubtitle": "Find the best provider match for this book.", "searchSubtitle": "Find the best provider match for this book.",
"compareSubtitle": "Review differences and apply only what you want." "compareSubtitle": "Review differences and apply only what you want."
@@ -5949,6 +5952,8 @@
"score": "Score", "score": "Score",
"scoreTooltip": "Metadata score {score} out of 100", "scoreTooltip": "Metadata score {score} out of 100",
"emptyFields": "{count, plural, one {empty field} other {empty fields}}", "emptyFields": "{count, plural, one {empty field} other {empty fields}}",
"emptyFieldsCount": "{count, plural, one {# empty field} other {# empty fields}}",
"emptyFieldsHeading": "Empty metadata fields",
"emptyFieldsTooltip": "Not filled in yet: {fields}. Search online to look them up.", "emptyFieldsTooltip": "Not filled in yet: {fields}. Search online to look them up.",
"libraryRow": "Library", "libraryRow": "Library",
"primaryRow": "Primary", "primaryRow": "Primary",