mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -27,6 +27,7 @@ import type {
|
||||
} 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { api } from '@/lib/api'
|
||||
import { metadataScoreColor } from '@/lib/metadata-score-color'
|
||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
@@ -301,7 +302,6 @@ const emptyFields = computed(() => {
|
||||
]
|
||||
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 metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value)))
|
||||
|
||||
@@ -871,17 +871,25 @@ function handleCoverChanged(medium: CoverMedium | null) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="emptyFields.length > 0"
|
||||
type="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"
|
||||
:title="emptyFieldsTitle"
|
||||
@click="handleOpenSearch"
|
||||
>
|
||||
<TriangleAlert class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ emptyFields.length }}</span>
|
||||
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
|
||||
</button>
|
||||
<Popover v-if="emptyFields.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
:aria-label="t('book.detail.editMetadata.emptyFieldsCount', { count: emptyFields.length })"
|
||||
>
|
||||
<TriangleAlert class="size-3.5 shrink-0 text-warning" aria-hidden="true" />
|
||||
<span>{{ emptyFields.length }}</span>
|
||||
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
|
||||
</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" />
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<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 { X } from '@lucide/vue'
|
||||
import { SlidersHorizontal, X } from '@lucide/vue'
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { getBookMediaProfile } from '@bookorbit/types'
|
||||
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
|
||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||
import { useMetadataSearchPreferences } from '../../../composables/useMetadataSearchPreferences'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
import type { MetadataDiffApply } from '../../../composables/useMetadataDiff'
|
||||
import type { SecondCoverInput } from '../../../composables/useSecondCoverRow'
|
||||
@@ -22,6 +24,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const optionsId = useId()
|
||||
const { autoSearchOnOpen } = useMetadataSearchPreferences()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const isAudiobookSearch = computed(() => getBookMediaProfile(props.book.files).primaryMediaKind === 'audiobook')
|
||||
@@ -117,10 +121,17 @@ const subtitle = computed(() =>
|
||||
.join(' \u00b7 '),
|
||||
)
|
||||
|
||||
let active = true
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const shouldSearchOnOpen = autoSearchOnOpen.value
|
||||
await loadProviders(props.book.id)
|
||||
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) {
|
||||
@@ -221,7 +232,39 @@ function handleCancel() {
|
||||
@retry-provider="handleRetry"
|
||||
@apply="handleApply"
|
||||
@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>
|
||||
</Sheet>
|
||||
</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 () => {
|
||||
const wrapper = mountTab(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
+132
@@ -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"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
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" />
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
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" />
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<slot name="search-options" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -116,7 +116,9 @@ defineExpose({ focusQuery })
|
||||
<template>
|
||||
<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">
|
||||
<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
|
||||
v-if="providers.length"
|
||||
: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'] }
|
||||
|
||||
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}) {
|
||||
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}, slots: Record<string, string> = {}) {
|
||||
return mount(MetadataMatchWorkspace, {
|
||||
attachTo: document.body,
|
||||
slots,
|
||||
props: {
|
||||
current,
|
||||
providerIds: { amazon: 'B002KP6DXQ' },
|
||||
@@ -87,6 +88,14 @@ async function choose(wrapper: VueWrapper, key: string, option: string) {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const wrapper = mountWorkspace()
|
||||
|
||||
|
||||
@@ -434,7 +434,9 @@ defineExpose({ focusQuery: () => rail.value?.focusQuery() })
|
||||
@select-field-rules="handleSelectFieldRules"
|
||||
@retry-provider="handleRetry"
|
||||
@select="select"
|
||||
/>
|
||||
>
|
||||
<template #search-options><slot name="search-options" /></template>
|
||||
</MetadataMatchRail>
|
||||
</div>
|
||||
<div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'">
|
||||
<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 }
|
||||
}
|
||||
@@ -5730,6 +5730,9 @@
|
||||
},
|
||||
"searchDrawer": {
|
||||
"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",
|
||||
"searchSubtitle": "Find the best provider match for this book.",
|
||||
"compareSubtitle": "Review differences and apply only what you want."
|
||||
@@ -5949,6 +5952,8 @@
|
||||
"score": "Score",
|
||||
"scoreTooltip": "Metadata score {score} out of 100",
|
||||
"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.",
|
||||
"libraryRow": "Library",
|
||||
"primaryRow": "Primary",
|
||||
|
||||
Reference in New Issue
Block a user